Add research workspace and graphing flows

This commit is contained in:
2026-03-07 16:52:35 -05:00
parent db01f207a5
commit 62bacdf104
37 changed files with 5494 additions and 434 deletions

View File

@@ -16,6 +16,7 @@ import {
import { import {
BrainCircuit, BrainCircuit,
ChartNoAxesCombined, ChartNoAxesCombined,
NotebookTabs,
NotebookPen, NotebookPen,
RefreshCcw, RefreshCcw,
Search, Search,
@@ -29,6 +30,7 @@ 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 { buildGraphingHref } from '@/lib/graphing/catalog';
import { import {
createResearchJournalEntry, createResearchJournalEntry,
deleteResearchJournalEntry, deleteResearchJournalEntry,
@@ -407,6 +409,14 @@ function AnalysisPageContent() {
> >
Open filing stream Open filing stream
</Link> </Link>
<Link
href={buildGraphingHref(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 graphing
</Link>
</> </>
) : null} ) : null}
</form> </form>
@@ -756,113 +766,70 @@ function AnalysisPageContent() {
)} )}
</Panel> </Panel>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_1.3fr]"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-[0.95fr_1.3fr]">
<Panel <Panel
title={editingJournalId === null ? 'Research Journal' : 'Edit Journal Entry'} title="Research Summary"
subtitle="Private markdown notes for this company. Linked filing notes update the coverage review timestamp." subtitle="The full thesis workflow now lives in the dedicated Research workspace."
actions={(
<Link
href={`/research?ticker=${encodeURIComponent(activeTicker)}`}
onMouseEnter={() => prefetchResearchTicker(activeTicker)}
onFocus={() => prefetchResearchTicker(activeTicker)}
className="inline-flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
<NotebookTabs className="size-4" />
Open research
</Link>
)}
> >
<form onSubmit={saveJournalEntry} className="space-y-3"> <div className="space-y-4">
<div> <div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Title</label> <p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Workspace focus</p>
<Input <p className="mt-2 text-sm leading-6 text-[color:var(--terminal-bright)]">
value={journalForm.title} Use the research surface to manage the typed library, attach evidence to memo sections, upload diligence files, and assemble the packet view for investor review.
aria-label="Journal title" </p>
onChange={(event) => setJournalForm((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Investment thesis checkpoint, risk note, follow-up..."
/>
</div> </div>
<div> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Linked Filing (optional)</label> <div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<Input <p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Stored research entries</p>
value={journalForm.accessionNumber} <p className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">{journalEntries.length}</p>
aria-label="Journal linked filing" </div>
onChange={(event) => setJournalForm((prev) => ({ ...prev, accessionNumber: event.target.value }))} <div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
placeholder="0000000000-26-000001" <p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Latest update</p>
/> <p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{journalEntries[0] ? formatDateTime(journalEntries[0].updated_at) : 'No research activity yet'}</p>
</div>
</div> </div>
<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>
<Panel title="Journal Timeline" subtitle={`${journalEntries.length} stored entries for ${activeTicker}.`}> <Panel title="Recent Research Feed" subtitle={`Previewing the latest ${Math.min(journalEntries.length, 4)} research entries for ${activeTicker}.`}>
{journalLoading ? ( {journalLoading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading journal entries...</p> <p className="text-sm text-[color:var(--terminal-muted)]">Loading research entries...</p>
) : journalEntries.length === 0 ? ( ) : 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> <p className="text-sm text-[color:var(--terminal-muted)]">No research saved yet. Use AI memo saves from reports or open the Research workspace to start building the thesis.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{journalEntries.map((entry) => { {journalEntries.slice(0, 4).map((entry) => (
const canEdit = entry.entry_type !== 'status_change'; <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">
return ( <div>
<article key={entry.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4"> <p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
<div className="flex flex-wrap items-start justify-between gap-3"> {entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
<div> </p>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]"> <h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{entry.title ?? 'Untitled entry'}</h4>
{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> </div>
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{entry.body_markdown}</p> <Link
{canEdit ? ( href={`/research?ticker=${encodeURIComponent(activeTicker)}`}
<div className="mt-4 flex flex-wrap items-center gap-2"> onMouseEnter={() => prefetchResearchTicker(activeTicker)}
<Button onFocus={() => prefetchResearchTicker(activeTicker)}
variant="ghost" className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
className="px-2 py-1 text-xs" >
onClick={() => beginEditJournalEntry(entry)} Open workspace
> </Link>
<SquarePen className="size-3" /> </div>
Edit <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{entry.body_markdown}</p>
</Button> </article>
<Button ))}
variant="danger"
className="px-2 py-1 text-xs"
onClick={() => {
void removeJournalEntry(entry);
}}
>
<Trash2 className="size-3" />
Delete
</Button>
</div>
) : null}
</article>
);
})}
</div> </div>
)} )}
</Panel> </Panel>
@@ -871,7 +838,7 @@ function AnalysisPageContent() {
<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 + research journal Analysis scope: price + filings + ai synthesis + research workspace
</div> </div>
</Panel> </Panel>
</AppShell> </AppShell>

View File

@@ -14,7 +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'; import { createResearchArtifact } from '@/lib/api';
function formatFilingDate(value: string) { function formatFilingDate(value: string) {
const date = new Date(value); const date = new Date(value);
@@ -87,6 +87,7 @@ export default function AnalysisReportPage() {
const resolvedTicker = report?.ticker ?? tickerFromRoute; const resolvedTicker = report?.ticker ?? tickerFromRoute;
const analysisHref = resolvedTicker ? `/analysis?ticker=${encodeURIComponent(resolvedTicker)}` : '/analysis'; const analysisHref = resolvedTicker ? `/analysis?ticker=${encodeURIComponent(resolvedTicker)}` : '/analysis';
const researchHref = resolvedTicker ? `/research?ticker=${encodeURIComponent(resolvedTicker)}` : '/research';
const filingsHref = resolvedTicker ? `/filings?ticker=${encodeURIComponent(resolvedTicker)}` : '/filings'; const filingsHref = resolvedTicker ? `/filings?ticker=${encodeURIComponent(resolvedTicker)}` : '/filings';
return ( return (
@@ -135,6 +136,15 @@ export default function AnalysisReportPage() {
<ArrowLeft className="size-3" /> <ArrowLeft className="size-3" />
Back to filings Back to filings
</Link> </Link>
<Link
href={researchHref}
onMouseEnter={() => prefetchResearchTicker(resolvedTicker)}
onFocus={() => prefetchResearchTicker(resolvedTicker)}
className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
<ArrowLeft className="size-3" />
Open research
</Link>
</div> </div>
</Panel> </Panel>
@@ -193,11 +203,14 @@ export default function AnalysisReportPage() {
setJournalNotice(null); setJournalNotice(null);
try { try {
await createResearchJournalEntry({ await createResearchArtifact({
ticker: report.ticker, ticker: report.ticker,
kind: 'ai_report',
source: 'system',
subtype: 'filing_analysis',
accessionNumber: report.accessionNumber, accessionNumber: report.accessionNumber,
entryType: 'filing_note',
title: `${report.filingType} AI memo`, title: `${report.filingType} AI memo`,
summary: report.summary,
bodyMarkdown: [ bodyMarkdown: [
`Stored AI memo for ${report.companyName} (${report.ticker}).`, `Stored AI memo for ${report.companyName} (${report.ticker}).`,
`Accession: ${report.accessionNumber}`, `Accession: ${report.accessionNumber}`,
@@ -205,19 +218,21 @@ export default function AnalysisReportPage() {
report.summary report.summary
].join('\n') ].join('\n')
}); });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(report.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(report.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(report.ticker) }); void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(report.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(report.ticker) }); void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(report.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() }); void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
setJournalNotice('Saved to the company research journal.'); setJournalNotice('Saved to the company research library.');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save report to journal'); setError(err instanceof Error ? err.message : 'Unable to save report to library');
} finally { } finally {
setSavingToJournal(false); setSavingToJournal(false);
} }
}} }}
> >
<NotebookPen className="size-4" /> <NotebookPen className="size-4" />
{savingToJournal ? 'Saving...' : 'Add to journal'} {savingToJournal ? 'Saving...' : 'Save to library'}
</Button> </Button>
</div> </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)]">

View File

@@ -1,8 +1,25 @@
import { app } from '@/lib/server/api/app'; import { app } from '@/lib/server/api/app';
export const GET = app.fetch; export async function GET(request: Request) {
export const POST = app.fetch; return await app.fetch(request);
export const PATCH = app.fetch; }
export const PUT = app.fetch;
export const DELETE = app.fetch; export async function POST(request: Request) {
export const OPTIONS = app.fetch; return await app.fetch(request);
}
export async function PATCH(request: Request) {
return await app.fetch(request);
}
export async function PUT(request: Request) {
return await app.fetch(request);
}
export async function DELETE(request: Request) {
return await app.fetch(request);
}
export async function OPTIONS(request: Request) {
return await app.fetch(request);
}

View File

@@ -14,7 +14,7 @@ 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 { import {
createResearchJournalEntry, createResearchArtifact,
queueFilingAnalysis, queueFilingAnalysis,
queueFilingSync queueFilingSync
} from '@/lib/api'; } from '@/lib/api';
@@ -202,27 +202,39 @@ function FilingsPageContent() {
} }
}; };
const addToJournal = async (filing: Filing) => { const saveToLibrary = async (filing: Filing) => {
try { try {
await createResearchJournalEntry({ await createResearchArtifact({
ticker: filing.ticker, ticker: filing.ticker,
kind: 'filing',
source: 'system',
subtype: 'filing_snapshot',
accessionNumber: filing.accession_number, accessionNumber: filing.accession_number,
entryType: 'filing_note', title: `${filing.filing_type} filing snapshot`,
title: `${filing.filing_type} filing note`, summary: filing.analysis?.text ?? filing.analysis?.legacyInsights ?? `Captured filing ${filing.accession_number}.`,
bodyMarkdown: [ bodyMarkdown: [
`Captured filing note for ${filing.company_name} (${filing.ticker}).`, `Captured filing note for ${filing.company_name} (${filing.ticker}).`,
`Filed: ${formatFilingDate(filing.filing_date)}`, `Filed: ${formatFilingDate(filing.filing_date)}`,
`Accession: ${filing.accession_number}`, `Accession: ${filing.accession_number}`,
'', '',
filing.analysis?.text ?? filing.analysis?.legacyInsights ?? 'Follow up on this filing from the stream.' filing.analysis?.text ?? filing.analysis?.legacyInsights ?? 'Follow up on this filing from the stream.'
].join('\n') ].join('\n'),
metadata: {
filingType: filing.filing_type,
filingDate: filing.filing_date,
filingUrl: filing.filing_url,
submissionUrl: filing.submission_url ?? null,
primaryDocument: filing.primary_document ?? null
}
}); });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(filing.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(filing.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(filing.ticker) }); void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(filing.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(filing.ticker) }); void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(filing.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() }); void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} journal.`); setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} research library.`);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add filing to journal'); setError(err instanceof Error ? err.message : 'Failed to save filing to library');
} }
}; };
@@ -411,11 +423,11 @@ function FilingsPageContent() {
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => void addToJournal(filing)} onClick={() => void saveToLibrary(filing)}
className="px-2 py-1 text-xs" className="px-2 py-1 text-xs"
> >
<NotebookPen className="size-3" /> <NotebookPen className="size-3" />
Add to journal Save to library
</Button> </Button>
{hasAnalysis ? ( {hasAnalysis ? (
<Link <Link
@@ -489,7 +501,7 @@ function FilingsPageContent() {
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => void addToJournal(filing)} onClick={() => void saveToLibrary(filing)}
className="px-2 py-1 text-xs" className="px-2 py-1 text-xs"
> >
<NotebookPen className="size-3" /> <NotebookPen className="size-3" />

View File

@@ -40,6 +40,7 @@ import {
formatPercent, formatPercent,
type NumberScaleUnit type NumberScaleUnit
} from '@/lib/format'; } from '@/lib/format';
import { buildGraphingHref } from '@/lib/graphing/catalog';
import { queryKeys } from '@/lib/query/keys'; import { queryKeys } from '@/lib/query/keys';
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options'; import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
import type { import type {
@@ -630,14 +631,24 @@ function FinancialsPageContent() {
Load Financials Load Financials
</Button> </Button>
{financials ? ( {financials ? (
<Link <>
href={`/analysis?ticker=${financials.company.ticker}`} <Link
onMouseEnter={() => prefetchResearchTicker(financials.company.ticker)} href={`/analysis?ticker=${financials.company.ticker}`}
onFocus={() => prefetchResearchTicker(financials.company.ticker)} onMouseEnter={() => prefetchResearchTicker(financials.company.ticker)}
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" onFocus={() => prefetchResearchTicker(financials.company.ticker)}
> className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
Open analysis >
</Link> Open analysis
</Link>
<Link
href={buildGraphingHref(financials.company.ticker)}
onMouseEnter={() => prefetchResearchTicker(financials.company.ticker)}
onFocus={() => prefetchResearchTicker(financials.company.ticker)}
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Open graphing
</Link>
</>
) : null} ) : null}
</form> </form>
</Panel> </Panel>

664
app/graphing/page.tsx Normal file
View File

@@ -0,0 +1,664 @@
'use client';
import { useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import Link from 'next/link';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { BarChart3, RefreshCcw, Search, X } from 'lucide-react';
import { AppShell } from '@/components/shell/app-shell';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Panel } from '@/components/ui/panel';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import {
GRAPH_CADENCE_OPTIONS,
GRAPH_CHART_OPTIONS,
GRAPH_SCALE_OPTIONS,
GRAPH_SURFACE_LABELS,
buildGraphingHref,
getGraphMetricDefinition,
metricsForSurfaceAndCadence,
normalizeGraphTickers,
parseGraphingParams,
resolveGraphMetric,
serializeGraphingParams,
type GraphChartKind,
type GraphMetricDefinition,
type GraphingUrlState
} from '@/lib/graphing/catalog';
import {
buildGraphingComparisonData,
type GraphingChartDatum,
type GraphingFetchResult,
type GraphingLatestValueRow,
type GraphingSeriesPoint
} from '@/lib/graphing/series';
import { ApiError } from '@/lib/api';
import {
formatCurrencyByScale,
formatPercent,
type NumberScaleUnit
} from '@/lib/format';
import type {
FinancialCadence,
FinancialUnit
} from '@/lib/types';
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
import { cn } from '@/lib/utils';
const CHART_COLORS = ['#68ffd5', '#5fd3ff', '#ffd08a', '#ff8a8a', '#c39bff'] as const;
const CHART_MUTED = '#b4ced9';
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
type TooltipEntry = {
dataKey?: string | number;
color?: string;
value?: number | string | null;
payload?: GraphingChartDatum;
};
function formatLongDate(value: string | null) {
if (!value) {
return 'n/a';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return 'n/a';
}
return format(parsed, 'MMM dd, yyyy');
}
function formatShortDate(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return format(parsed, 'MMM yyyy');
}
function formatMetricValue(
value: number | null | undefined,
unit: FinancialUnit,
scale: NumberScaleUnit
) {
if (value === null || value === undefined) {
return 'n/a';
}
switch (unit) {
case 'currency':
return formatCurrencyByScale(value, scale);
case 'percent':
return formatPercent(value * 100);
case 'ratio':
return `${value.toFixed(2)}x`;
case 'shares':
case 'count':
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(value);
default:
return String(value);
}
}
function formatChangeValue(
value: number | null,
unit: FinancialUnit,
scale: NumberScaleUnit
) {
if (value === null) {
return 'n/a';
}
if (unit === 'percent') {
const signed = value >= 0 ? '+' : '';
return `${signed}${formatPercent(value * 100)}`;
}
if (unit === 'ratio') {
const signed = value >= 0 ? '+' : '';
return `${signed}${value.toFixed(2)}x`;
}
if (unit === 'currency') {
const formatted = formatCurrencyByScale(Math.abs(value), scale);
return value >= 0 ? `+${formatted}` : `-${formatted}`;
}
const formatted = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(Math.abs(value));
return value >= 0 ? `+${formatted}` : `-${formatted}`;
}
function tickerPillClass(disabled?: boolean) {
return cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs',
disabled
? 'border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)]'
: 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]'
);
}
function ControlSection<T extends string>(props: {
label: string;
value: T;
options: Array<{ value: T; label: string }>;
onChange: (value: T) => void;
ariaLabel: string;
}) {
return (
<div className="space-y-2">
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">{props.label}</p>
<div className="flex flex-wrap gap-2">
{props.options.map((option) => (
<Button
key={option.value}
type="button"
aria-label={`${props.ariaLabel} ${option.label}`}
variant={option.value === props.value ? 'primary' : 'ghost'}
className="px-3 py-1.5 text-xs"
onClick={() => props.onChange(option.value)}
>
{option.label}
</Button>
))}
</div>
</div>
);
}
function ComparisonTooltip(props: {
active?: boolean;
payload?: TooltipEntry[];
metric: GraphMetricDefinition;
scale: NumberScaleUnit;
}) {
if (!props.active || !props.payload || props.payload.length === 0) {
return null;
}
const datum = props.payload[0]?.payload;
if (!datum) {
return null;
}
const entries = props.payload
.filter((entry) => typeof entry.dataKey === 'string')
.map((entry) => {
const ticker = entry.dataKey as string;
const meta = datum[`meta__${ticker}`] as GraphingSeriesPoint | undefined;
return {
ticker,
color: entry.color ?? CHART_MUTED,
value: typeof entry.value === 'number' ? entry.value : null,
meta
};
});
return (
<div
className="min-w-[220px] rounded-xl border px-3 py-3 text-sm shadow-[0_16px_40px_rgba(0,0,0,0.34)]"
style={{
backgroundColor: CHART_TOOLTIP_BG,
borderColor: CHART_TOOLTIP_BORDER
}}
>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{formatLongDate(entries[0]?.meta?.dateKey ?? null)}
</p>
<div className="mt-3 space-y-2">
{entries.map((entry) => (
<div key={entry.ticker} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:rgba(4,16,24,0.72)] px-2 py-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="size-2 rounded-full" style={{ backgroundColor: entry.color }} aria-hidden="true" />
<span className="font-medium text-[color:var(--terminal-bright)]">{entry.ticker}</span>
</div>
<span className="text-[color:var(--terminal-bright)]">
{formatMetricValue(entry.value, props.metric.unit, props.scale)}
</span>
</div>
{entry.meta ? (
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">
{entry.meta.filingType} · {entry.meta.periodLabel}
</p>
) : null}
</div>
))}
</div>
</div>
);
}
export default function GraphingPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading graphing desk...</div>}>
<GraphingPageContent />
</Suspense>
);
}
function GraphingPageContent() {
const { isPending, isAuthenticated } = useAuthGuard();
const searchParams = useSearchParams();
const router = useRouter();
const queryClient = useQueryClient();
const { prefetchResearchTicker } = useLinkPrefetch();
const graphState = useMemo(() => parseGraphingParams(searchParams), [searchParams]);
const canonicalQuery = useMemo(() => serializeGraphingParams(graphState), [graphState]);
const currentQuery = searchParams.toString();
const [tickerInput, setTickerInput] = useState(graphState.tickers.join(', '));
const [results, setResults] = useState<GraphingFetchResult[]>([]);
const [loading, setLoading] = useState(true);
const [refreshNonce, setRefreshNonce] = useState(0);
useEffect(() => {
setTickerInput(graphState.tickers.join(', '));
}, [graphState.tickers]);
useEffect(() => {
if (currentQuery !== canonicalQuery) {
router.replace(`/graphing?${canonicalQuery}`, { scroll: false });
}
}, [canonicalQuery, currentQuery, router]);
const replaceGraphState = useCallback((patch: Partial<GraphingUrlState>) => {
const nextSurface = patch.surface ?? graphState.surface;
const nextCadence = patch.cadence ?? graphState.cadence;
const nextTickers = patch.tickers && patch.tickers.length > 0 ? patch.tickers : graphState.tickers;
const nextMetric = resolveGraphMetric(
nextSurface,
nextCadence,
patch.metric ?? graphState.metric
);
const nextState: GraphingUrlState = {
tickers: nextTickers,
surface: nextSurface,
cadence: nextCadence,
metric: nextMetric,
chart: patch.chart ?? graphState.chart,
scale: patch.scale ?? graphState.scale
};
router.replace(`/graphing?${serializeGraphingParams(nextState)}`, { scroll: false });
}, [graphState, router]);
useEffect(() => {
if (isPending || !isAuthenticated) {
return;
}
let cancelled = false;
async function loadComparisonSet() {
setLoading(true);
const settled = await Promise.allSettled(graphState.tickers.map(async (ticker) => {
const response = await queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({
ticker,
surfaceKind: graphState.surface,
cadence: graphState.cadence,
includeDimensions: false,
includeFacts: false,
limit: 16
}));
return response.financials;
}));
if (cancelled) {
return;
}
setResults(settled.map((entry, index) => {
const ticker = graphState.tickers[index] ?? `ticker-${index + 1}`;
if (entry.status === 'fulfilled') {
return {
ticker,
financials: entry.value
} satisfies GraphingFetchResult;
}
const reason = entry.reason instanceof ApiError
? entry.reason.message
: entry.reason instanceof Error
? entry.reason.message
: 'Unable to load financial history';
return {
ticker,
error: reason
} satisfies GraphingFetchResult;
}));
setLoading(false);
}
void loadComparisonSet();
return () => {
cancelled = true;
};
}, [graphState, isAuthenticated, isPending, queryClient, refreshNonce]);
const metricOptions = useMemo(() => metricsForSurfaceAndCadence(graphState.surface, graphState.cadence), [graphState.cadence, graphState.surface]);
const selectedMetric = useMemo(
() => getGraphMetricDefinition(graphState.surface, graphState.cadence, graphState.metric),
[graphState.cadence, graphState.metric, graphState.surface]
);
const comparison = useMemo(() => buildGraphingComparisonData({
results,
surface: graphState.surface,
metric: graphState.metric
}), [graphState.metric, graphState.surface, results]);
const hasCurrencyScale = selectedMetric?.unit === 'currency';
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading graphing desk...</div>;
}
return (
<AppShell
title="Graphing"
subtitle="Compare one normalized filing metric across multiple companies with shareable chart state."
activeTicker={graphState.tickers[0] ?? null}
actions={(
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
onClick={() => setRefreshNonce((current) => current + 1)}
>
<RefreshCcw className="size-4" />
Refresh
</Button>
<Button
variant="secondary"
onClick={() => replaceGraphState(parseGraphingParams(new URLSearchParams()))}
>
<BarChart3 className="size-4" />
Reset View
</Button>
</div>
)}
>
<Panel title="Compare Set" subtitle="Enter up to five tickers. Duplicates are removed and the first ticker anchors research context.">
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
const nextTickers = normalizeGraphTickers(tickerInput);
replaceGraphState({ tickers: nextTickers.length > 0 ? nextTickers : [...graphState.tickers] });
}}
>
<div className="flex flex-wrap items-center gap-3">
<Input
aria-label="Compare tickers"
value={tickerInput}
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
placeholder="MSFT, AAPL, NVDA"
className="min-w-[260px] flex-1"
/>
<Button type="submit">
<Search className="size-4" />
Update Compare Set
</Button>
<Link
href={buildGraphingHref(graphState.tickers[0] ?? null)}
onMouseEnter={() => {
if (graphState.tickers[0]) {
prefetchResearchTicker(graphState.tickers[0]);
}
}}
onFocus={() => {
if (graphState.tickers[0]) {
prefetchResearchTicker(graphState.tickers[0]);
}
}}
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Open canonical graphing URL
</Link>
</div>
<div className="flex flex-wrap gap-2">
{graphState.tickers.map((ticker, index) => (
<span key={ticker} className={tickerPillClass(index === 0)}>
{ticker}
<button
type="button"
aria-label={`Remove ${ticker}`}
disabled={graphState.tickers.length === 1}
className="rounded-full p-0.5 text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)] disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
const nextTickers = graphState.tickers.filter((entry) => entry !== ticker);
if (nextTickers.length > 0) {
replaceGraphState({ tickers: nextTickers });
}
}}
>
<X className="size-3" />
</button>
</span>
))}
</div>
</form>
</Panel>
<Panel title="Chart Controls" subtitle="Surface, metric, cadence, chart style, and scale stay in the URL for deep-linking.">
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-5">
<ControlSection
label="Surface"
ariaLabel="Graph surface"
value={graphState.surface}
options={Object.entries(GRAPH_SURFACE_LABELS).map(([value, label]) => ({ value: value as GraphingUrlState['surface'], label }))}
onChange={(value) => replaceGraphState({ surface: value })}
/>
<ControlSection
label="Cadence"
ariaLabel="Graph cadence"
value={graphState.cadence}
options={GRAPH_CADENCE_OPTIONS}
onChange={(value) => replaceGraphState({ cadence: value as FinancialCadence })}
/>
<ControlSection
label="Chart Type"
ariaLabel="Chart type"
value={graphState.chart}
options={GRAPH_CHART_OPTIONS}
onChange={(value) => replaceGraphState({ chart: value as GraphChartKind })}
/>
{hasCurrencyScale ? (
<ControlSection
label="Scale"
ariaLabel="Value scale"
value={graphState.scale}
options={GRAPH_SCALE_OPTIONS}
onChange={(value) => replaceGraphState({ scale: value as NumberScaleUnit })}
/>
) : null}
</div>
<div className="space-y-2">
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">Metric</p>
<select
aria-label="Metric selector"
value={graphState.metric}
onChange={(event) => replaceGraphState({ metric: event.target.value })}
className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2.5 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)]"
>
{metricOptions.map((option) => (
<option key={option.key} value={option.key} className="bg-[#07161f]">
{option.label}
</option>
))}
</select>
{selectedMetric ? (
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3 text-sm text-[color:var(--terminal-muted)]">
<p className="text-[color:var(--terminal-bright)]">{selectedMetric.label}</p>
<p className="mt-1">
{GRAPH_SURFACE_LABELS[selectedMetric.surface]} · {selectedMetric.category.replace(/_/g, ' ')} · unit {selectedMetric.unit}
</p>
</div>
) : null}
</div>
</div>
</Panel>
<Panel title="Comparison Chart" subtitle="One metric, multiple companies, aligned by actual reported dates.">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading comparison chart...</p>
) : !selectedMetric || !comparison.hasAnyData ? (
<div className="rounded-xl border border-dashed border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-4 py-6">
<p className="text-sm text-[color:var(--terminal-bright)]">No chart data available for the selected compare set.</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Try a different metric, cadence, or company basket.</p>
</div>
) : (
<>
{comparison.hasPartialData ? (
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3 text-sm text-[color:var(--terminal-muted)]">
Partial coverage detected. Some companies are missing values for this metric or failed to load, but the remaining series still render.
</div>
) : null}
<div className="mb-4 flex flex-wrap gap-2">
{comparison.companies.map((company, index) => (
<span key={company.ticker} className={tickerPillClass(company.status !== 'ready')}>
<span
className="size-2 rounded-full"
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
{company.ticker}
<span className="text-[color:var(--terminal-muted)]">{company.companyName}</span>
</span>
))}
</div>
<div className="h-[360px]">
<ResponsiveContainer width="100%" height="100%">
{graphState.chart === 'line' ? (
<LineChart data={comparison.chartData}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
<XAxis
dataKey="dateKey"
stroke={CHART_MUTED}
fontSize={12}
minTickGap={20}
tickFormatter={(value: string) => formatShortDate(value)}
/>
<YAxis
stroke={CHART_MUTED}
fontSize={12}
tickFormatter={(value: number) => formatMetricValue(value, selectedMetric.unit, graphState.scale)}
/>
<Tooltip content={<ComparisonTooltip metric={selectedMetric} scale={graphState.scale} />} />
{comparison.companies.map((company, index) => (
<Line
key={company.ticker}
type="monotone"
dataKey={company.ticker}
connectNulls={false}
stroke={CHART_COLORS[index % CHART_COLORS.length]}
strokeWidth={2.5}
dot={false}
name={company.ticker}
/>
))}
</LineChart>
) : (
<BarChart data={comparison.chartData}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
<XAxis
dataKey="dateKey"
stroke={CHART_MUTED}
fontSize={12}
minTickGap={20}
tickFormatter={(value: string) => formatShortDate(value)}
/>
<YAxis
stroke={CHART_MUTED}
fontSize={12}
tickFormatter={(value: number) => formatMetricValue(value, selectedMetric.unit, graphState.scale)}
/>
<Tooltip content={<ComparisonTooltip metric={selectedMetric} scale={graphState.scale} />} />
{comparison.companies.map((company, index) => (
<Bar
key={company.ticker}
dataKey={company.ticker}
fill={CHART_COLORS[index % CHART_COLORS.length]}
radius={[6, 6, 0, 0]}
name={company.ticker}
/>
))}
</BarChart>
)}
</ResponsiveContainer>
</div>
</>
)}
</Panel>
<Panel title="Latest Values" subtitle="Most recent reported point per company, plus the prior point and one-step change.">
<div className="overflow-x-auto">
<table className="data-table min-w-[920px]">
<thead>
<tr>
<th>Company</th>
<th>Latest</th>
<th>Prior</th>
<th>Change</th>
<th>Period</th>
<th>Filing</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{comparison.latestRows.map((row, index) => (
<tr key={row.ticker}>
<td>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span
className="size-2 rounded-full"
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span>{row.ticker}</span>
</div>
<span className="text-xs text-[color:var(--terminal-muted)]">{row.companyName}</span>
</div>
</td>
<td>{selectedMetric ? formatMetricValue(row.latestValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
<td>{selectedMetric ? formatMetricValue(row.priorValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
<td className={cn(row.changeValue !== null && row.changeValue >= 0 ? 'text-[color:var(--accent)]' : row.changeValue !== null ? 'text-[#ffb5b5]' : 'text-[color:var(--terminal-muted)]')}>
{selectedMetric ? formatChangeValue(row.changeValue, selectedMetric.unit, graphState.scale) : 'n/a'}
</td>
<td>{formatLongDate(row.latestDateKey)} {row.latestPeriodLabel ? <span className="text-xs text-[color:var(--terminal-muted)]">· {row.latestPeriodLabel}</span> : null}</td>
<td>{row.latestFilingType ?? 'n/a'}</td>
<td>
{row.status === 'ready' ? (
<span className="text-[color:var(--accent)]">Ready</span>
) : row.status === 'no_metric_data' ? (
<span className="text-[color:var(--terminal-muted)]">No metric data</span>
) : (
<span className="text-[#ffb5b5]">{row.errorMessage ?? 'Load failed'}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
</AppShell>
);
}

View File

@@ -15,6 +15,7 @@ import {
queuePortfolioInsights, queuePortfolioInsights,
queuePriceRefresh queuePriceRefresh
} from '@/lib/api'; } from '@/lib/api';
import { buildGraphingHref } from '@/lib/graphing/catalog';
import type { PortfolioInsight, PortfolioSummary, Task } from '@/lib/types'; import type { PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
import { formatCompactCurrency, formatCurrency, formatPercent } from '@/lib/format'; import { formatCompactCurrency, formatCurrency, formatPercent } from '@/lib/format';
import { queryKeys } from '@/lib/query/keys'; import { queryKeys } from '@/lib/query/keys';
@@ -203,6 +204,10 @@ export default function CommandCenterPage() {
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Financials</p> <p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Financials</p>
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Focus on multi-period filing metrics, margins, leverage, and balance sheet composition.</p> <p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Focus on multi-period filing metrics, margins, leverage, and balance sheet composition.</p>
</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)]" href={buildGraphingHref()}>
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Graphing</p>
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Compare one normalized metric across multiple companies with shareable chart state.</p>
</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)]"
href="/filings" href="/filings"

831
app/research/page.tsx Normal file
View File

@@ -0,0 +1,831 @@
'use client';
import { Suspense, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { format } from 'date-fns';
import { useQueryClient } from '@tanstack/react-query';
import { BookOpenText, Download, FilePlus2, Filter, FolderUp, Link2, NotebookPen, Search, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import {
addResearchMemoEvidence,
createResearchArtifact,
deleteResearchArtifact,
deleteResearchMemoEvidence,
getResearchArtifactFileUrl,
updateResearchArtifact,
uploadResearchArtifact,
upsertResearchMemo
} from '@/lib/api';
import { queryKeys } from '@/lib/query/keys';
import {
researchLibraryQueryOptions,
researchWorkspaceQueryOptions
} from '@/lib/query/options';
import type {
ResearchArtifact,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchMemo,
ResearchMemoSection,
ResearchWorkspace
} from '@/lib/types';
const MEMO_SECTIONS: Array<{ value: ResearchMemoSection; label: string }> = [
{ value: 'thesis', label: 'Thesis' },
{ value: 'variant_view', label: 'Variant View' },
{ value: 'catalysts', label: 'Catalysts' },
{ value: 'risks', label: 'Risks' },
{ value: 'disconfirming_evidence', label: 'Disconfirming Evidence' },
{ value: 'next_actions', label: 'Next Actions' }
];
const KIND_OPTIONS: Array<{ value: '' | ResearchArtifactKind; label: string }> = [
{ value: '', label: 'All artifacts' },
{ value: 'note', label: 'Notes' },
{ value: 'ai_report', label: 'AI memos' },
{ value: 'filing', label: 'Filings' },
{ value: 'upload', label: 'Uploads' },
{ value: 'status_change', label: 'Status events' }
];
const SOURCE_OPTIONS: Array<{ value: '' | ResearchArtifactSource; label: string }> = [
{ value: '', label: 'All sources' },
{ value: 'user', label: 'User-authored' },
{ value: 'system', label: 'System-generated' }
];
type NoteFormState = {
id: number | null;
title: string;
summary: string;
bodyMarkdown: string;
tags: string;
};
type MemoFormState = {
rating: string;
conviction: string;
timeHorizonMonths: string;
packetTitle: string;
packetSubtitle: string;
thesisMarkdown: string;
variantViewMarkdown: string;
catalystsMarkdown: string;
risksMarkdown: string;
disconfirmingEvidenceMarkdown: string;
nextActionsMarkdown: string;
};
const EMPTY_NOTE_FORM: NoteFormState = {
id: null,
title: '',
summary: '',
bodyMarkdown: '',
tags: ''
};
const EMPTY_MEMO_FORM: MemoFormState = {
rating: '',
conviction: '',
timeHorizonMonths: '',
packetTitle: '',
packetSubtitle: '',
thesisMarkdown: '',
variantViewMarkdown: '',
catalystsMarkdown: '',
risksMarkdown: '',
disconfirmingEvidenceMarkdown: '',
nextActionsMarkdown: ''
};
function parseTags(value: string) {
const unique = new Set<string>();
for (const segment of value.split(',')) {
const normalized = segment.trim();
if (!normalized) {
continue;
}
unique.add(normalized);
}
return [...unique];
}
function tagsToInput(tags: string[]) {
return tags.join(', ');
}
function formatTimestamp(value: string | null | undefined) {
if (!value) {
return 'n/a';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 'n/a';
}
return format(date, 'MMM dd, yyyy · HH:mm');
}
function toMemoForm(memo: ResearchMemo | null): MemoFormState {
if (!memo) {
return EMPTY_MEMO_FORM;
}
return {
rating: memo.rating ?? '',
conviction: memo.conviction ?? '',
timeHorizonMonths: memo.time_horizon_months ? String(memo.time_horizon_months) : '',
packetTitle: memo.packet_title ?? '',
packetSubtitle: memo.packet_subtitle ?? '',
thesisMarkdown: memo.thesis_markdown,
variantViewMarkdown: memo.variant_view_markdown,
catalystsMarkdown: memo.catalysts_markdown,
risksMarkdown: memo.risks_markdown,
disconfirmingEvidenceMarkdown: memo.disconfirming_evidence_markdown,
nextActionsMarkdown: memo.next_actions_markdown
};
}
function noteFormFromArtifact(artifact: ResearchArtifact): NoteFormState {
return {
id: artifact.id,
title: artifact.title ?? '',
summary: artifact.summary ?? '',
bodyMarkdown: artifact.body_markdown ?? '',
tags: tagsToInput(artifact.tags)
};
}
export default function ResearchPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</div>}>
<ResearchPageContent />
</Suspense>
);
}
function ResearchPageContent() {
const { isPending, isAuthenticated } = useAuthGuard();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { prefetchResearchTicker } = useLinkPrefetch();
const [workspace, setWorkspace] = useState<ResearchWorkspace | null>(null);
const [library, setLibrary] = useState<ResearchArtifact[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [noteForm, setNoteForm] = useState<NoteFormState>(EMPTY_NOTE_FORM);
const [memoForm, setMemoForm] = useState<MemoFormState>(EMPTY_MEMO_FORM);
const [searchInput, setSearchInput] = useState('');
const [kindFilter, setKindFilter] = useState<'' | ResearchArtifactKind>('');
const [sourceFilter, setSourceFilter] = useState<'' | ResearchArtifactSource>('');
const [tagFilter, setTagFilter] = useState('');
const [linkedOnly, setLinkedOnly] = useState(false);
const [attachSection, setAttachSection] = useState<ResearchMemoSection>('thesis');
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadTitle, setUploadTitle] = useState('');
const [uploadSummary, setUploadSummary] = useState('');
const [uploadTags, setUploadTags] = useState('');
const ticker = useMemo(() => searchParams.get('ticker')?.trim().toUpperCase() ?? '', [searchParams]);
const deferredSearch = useDeferredValue(searchInput);
const loadWorkspace = async (symbol: string) => {
if (!symbol) {
setWorkspace(null);
setLibrary([]);
setLoading(false);
return;
}
const options = researchWorkspaceQueryOptions(symbol);
if (!queryClient.getQueryData(options.queryKey)) {
setLoading(true);
}
try {
const response = await queryClient.ensureQueryData(options);
setWorkspace(response.workspace);
setLibrary(response.workspace.library);
setMemoForm(toMemoForm(response.workspace.memo));
setError(null);
} catch (err) {
setWorkspace(null);
setLibrary([]);
setError(err instanceof Error ? err.message : 'Unable to load research workspace');
} finally {
setLoading(false);
}
};
const loadLibrary = async (symbol: string) => {
if (!symbol) {
return;
}
try {
const response = await queryClient.fetchQuery(researchLibraryQueryOptions({
ticker: symbol,
q: deferredSearch,
kind: kindFilter || undefined,
source: sourceFilter || undefined,
tag: tagFilter || undefined,
linkedToMemo: linkedOnly ? true : undefined,
limit: 100
}));
setLibrary(response.artifacts);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load filtered library');
}
};
const invalidateResearch = async (symbol: string) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }),
queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() })
]);
};
useEffect(() => {
if (!isPending && isAuthenticated) {
void loadWorkspace(ticker);
}
}, [isPending, isAuthenticated, ticker]);
useEffect(() => {
if (!workspace) {
return;
}
void loadLibrary(workspace.ticker);
}, [workspace?.ticker, deferredSearch, kindFilter, sourceFilter, tagFilter, linkedOnly]);
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</div>;
}
const saveNote = async () => {
if (!ticker) {
return;
}
try {
if (noteForm.id === null) {
await createResearchArtifact({
ticker,
kind: 'note',
source: 'user',
title: noteForm.title || undefined,
summary: noteForm.summary || undefined,
bodyMarkdown: noteForm.bodyMarkdown || undefined,
tags: parseTags(noteForm.tags)
});
setNotice('Saved note to the research library.');
} else {
await updateResearchArtifact(noteForm.id, {
title: noteForm.title || undefined,
summary: noteForm.summary || undefined,
bodyMarkdown: noteForm.bodyMarkdown || undefined,
tags: parseTags(noteForm.tags)
});
setNotice('Updated research note.');
}
setNoteForm(EMPTY_NOTE_FORM);
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save note');
}
};
const saveMemo = async () => {
if (!ticker) {
return;
}
try {
await upsertResearchMemo({
ticker,
rating: memoForm.rating ? memoForm.rating as ResearchMemo['rating'] : null,
conviction: memoForm.conviction ? memoForm.conviction as ResearchMemo['conviction'] : null,
timeHorizonMonths: memoForm.timeHorizonMonths ? Number(memoForm.timeHorizonMonths) : null,
packetTitle: memoForm.packetTitle || undefined,
packetSubtitle: memoForm.packetSubtitle || undefined,
thesisMarkdown: memoForm.thesisMarkdown,
variantViewMarkdown: memoForm.variantViewMarkdown,
catalystsMarkdown: memoForm.catalystsMarkdown,
risksMarkdown: memoForm.risksMarkdown,
disconfirmingEvidenceMarkdown: memoForm.disconfirmingEvidenceMarkdown,
nextActionsMarkdown: memoForm.nextActionsMarkdown
});
setNotice('Saved investment memo.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save investment memo');
}
};
const uploadFileToLibrary = async () => {
if (!ticker || !uploadFile) {
return;
}
try {
await uploadResearchArtifact({
ticker,
file: uploadFile,
title: uploadTitle || undefined,
summary: uploadSummary || undefined,
tags: parseTags(uploadTags)
});
setUploadFile(null);
setUploadTitle('');
setUploadSummary('');
setUploadTags('');
setNotice('Uploaded research file.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to upload research file');
}
};
const ensureMemo = async () => {
if (!ticker) {
throw new Error('Ticker is required');
}
if (workspace?.memo) {
return workspace.memo.id;
}
const response = await upsertResearchMemo({ ticker });
await invalidateResearch(ticker);
await loadWorkspace(ticker);
return response.memo.id;
};
const attachArtifact = async (artifact: ResearchArtifact) => {
try {
const memoId = await ensureMemo();
await addResearchMemoEvidence({
memoId,
artifactId: artifact.id,
section: attachSection
});
setNotice(`Attached evidence to ${MEMO_SECTIONS.find((item) => item.value === attachSection)?.label}.`);
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to attach evidence');
}
};
const availableTags = workspace?.availableTags ?? [];
const memoEvidenceCount = workspace?.packet.sections.reduce((sum, section) => sum + section.evidence.length, 0) ?? 0;
return (
<AppShell
title="Research"
subtitle="Build an investor-grade company dossier with evidence-linked memo sections, uploads, and a packet view."
activeTicker={ticker || null}
breadcrumbs={[
{ label: 'Analysis', href: ticker ? `/analysis?ticker=${encodeURIComponent(ticker)}` : '/analysis' },
{ label: 'Research' }
]}
actions={(
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
onClick={() => {
void invalidateResearch(ticker);
void loadWorkspace(ticker);
}}
>
<Sparkles className="size-4" />
Refresh
</Button>
{ticker ? (
<Link
href={`/analysis?ticker=${encodeURIComponent(ticker)}`}
onMouseEnter={() => prefetchResearchTicker(ticker)}
onFocus={() => prefetchResearchTicker(ticker)}
className="inline-flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
Open analysis
</Link>
) : null}
</div>
)}
>
{!ticker ? (
<Panel title="Select a company" subtitle="Open this page with `?ticker=NVDA` or use the Coverage and Analysis surfaces to pivot into research.">
<p className="text-sm text-[color:var(--terminal-muted)]">This workspace is company-first by design and activates once a ticker is selected.</p>
</Panel>
) : null}
{ticker && workspace ? (
<>
<Panel className="overflow-hidden border-[color:var(--line-strong)] bg-[linear-gradient(135deg,rgba(6,16,20,0.96),rgba(6,16,20,0.82)_45%,rgba(8,28,30,0.9))]">
<div className="grid gap-5 lg:grid-cols-[1.6fr_1fr]">
<div>
<p className="text-xs uppercase tracking-[0.22em] text-[color:var(--accent)]">Buy-Side Research Workspace</p>
<h2 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">{workspace.companyName ?? workspace.ticker}</h2>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
{workspace.coverage?.status ? `Coverage: ${workspace.coverage.status}` : 'Not yet on the coverage board'} ·
{' '}Last filing: {workspace.latestFilingDate ? formatTimestamp(workspace.latestFilingDate).split(' · ')[0] : 'n/a'} ·
{' '}Private by default
</p>
<div className="mt-4 flex flex-wrap gap-2">
{(workspace.coverage?.tags ?? []).map((tag) => (
<span key={tag} className="rounded-full border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.02)] px-3 py-1 text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
{tag}
</span>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4">
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Memo posture</p>
<p className="mt-2 text-lg font-semibold text-[color:var(--terminal-bright)]">
{workspace.memo?.rating ? workspace.memo.rating.replace('_', ' ') : 'Unrated'}
</p>
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">
Conviction: {workspace.memo?.conviction ?? 'unset'}
</p>
</div>
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4">
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Research depth</p>
<p className="mt-2 text-lg font-semibold text-[color:var(--terminal-bright)]">{workspace.library.length} artifacts</p>
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{memoEvidenceCount} evidence links in the packet</p>
</div>
</div>
</div>
</Panel>
{notice ? (
<Panel className="border-[color:var(--line-strong)] bg-[color:var(--panel-soft)]">
<p className="text-sm text-[color:var(--accent)]">{notice}</p>
</Panel>
) : null}
{error ? (
<Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p>
</Panel>
) : null}
{loading ? (
<Panel>
<p className="text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</p>
</Panel>
) : (
<>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.25fr_1.1fr]">
<Panel
title="Library Filters"
subtitle="Narrow the evidence set by structure, ownership, and memo linkage."
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
</div>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
<select aria-label="Artifact type filter" className="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)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
{KIND_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label>
<select aria-label="Artifact source filter" className="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)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}>
{SOURCE_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label>
<select aria-label="Artifact tag filter" className="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)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}>
<option value="">All tags</option>
{availableTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</div>
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]">
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} />
Show memo-linked evidence only
</label>
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
Access Model
</div>
<p className="mt-3 text-sm text-[color:var(--terminal-muted)]">All research artifacts are private to the authenticated user in this release. The data model is prepared for workspace scopes later.</p>
</div>
</div>
</Panel>
<div className="space-y-6">
<Panel
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'}
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library."
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
<textarea
aria-label="Research note body"
value={noteForm.bodyMarkdown}
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
placeholder="Write the actual research note, variant view, or diligence conclusion..."
className="min-h-[160px] 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)]"
/>
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
<div className="flex flex-wrap gap-2">
<Button onClick={() => void saveNote()}>
<FilePlus2 className="size-4" />
{noteForm.id === null ? 'Save note' : 'Update note'}
</Button>
{noteForm.id !== null ? (
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
Cancel edit
</Button>
) : null}
</div>
</div>
</Panel>
<Panel
title="Research Library"
subtitle={`${library.length} artifacts match the current filter set.`}
actions={(
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
{MEMO_SECTIONS.map((section) => (
<option key={section.value} value={section.value}>{section.label}</option>
))}
</select>
</div>
)}
>
<div className="space-y-3">
{library.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
) : (
library.map((artifact) => (
<article key={artifact.id} className="rounded-2xl 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)]">
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
</h4>
</div>
<div className="flex flex-wrap gap-2">
{artifact.kind === 'upload' && artifact.storage_path ? (
<a className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]" href={getResearchArtifactFileUrl(artifact.id)}>
<Download className="size-3" />
File
</a>
) : null}
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
<Link2 className="size-3" />
Attach
</Button>
{artifact.kind === 'note' ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
Edit
</Button>
) : null}
{artifact.source === 'user' || artifact.kind === 'upload' ? (
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchArtifact(artifact.id);
setNotice('Removed artifact from the library.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
) : null}
</div>
</div>
{artifact.summary ? (
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
) : null}
{artifact.body_markdown ? (
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{artifact.linked_to_memo ? (
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
) : null}
{artifact.accession_number ? (
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
) : null}
{artifact.tags.map((tag) => (
<button
key={`${artifact.id}-${tag}`}
type="button"
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]"
onClick={() => setTagFilter(tag)}
>
{tag}
</button>
))}
</div>
</article>
))
)}
</div>
</Panel>
<Panel
title="Upload Research"
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
<input
aria-label="Upload file"
type="file"
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
className="block 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)]"
/>
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
<UploadIcon />
Upload file
</Button>
</div>
</Panel>
</div>
<Panel
title="Investment Memo"
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
<option value="">Rating</option>
<option value="strong_buy">Strong Buy</option>
<option value="buy">Buy</option>
<option value="hold">Hold</option>
<option value="sell">Sell</option>
</select>
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
<option value="">Conviction</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
</div>
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
{MEMO_SECTIONS.map((section) => {
const fieldMap: Record<ResearchMemoSection, keyof MemoFormState> = {
thesis: 'thesisMarkdown',
variant_view: 'variantViewMarkdown',
catalysts: 'catalystsMarkdown',
risks: 'risksMarkdown',
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
next_actions: 'nextActionsMarkdown'
};
const field = fieldMap[section.value];
return (
<div key={section.value}>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
<textarea
aria-label={`Memo ${section.label}`}
value={memoForm[field]}
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
className="min-h-[108px] 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)]"
placeholder={`Write ${section.label.toLowerCase()}...`}
/>
</div>
);
})}
<Button onClick={() => void saveMemo()}>
<NotebookPen className="size-4" />
Save memo
</Button>
</div>
</Panel>
</div>
<Panel
title="Research Packet"
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-6">
{workspace.packet.sections.map((section) => (
<section key={section.section} className="rounded-2xl 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)]">Packet Section</p>
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
</div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
{section.body_markdown || 'No memo content yet for this section.'}
</p>
{section.evidence.length > 0 ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{section.evidence.map((item) => (
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
</div>
{workspace.memo ? (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchMemoEvidence(workspace.memo!.id, item.id);
setNotice('Removed memo evidence.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
}
}}
>
Remove
</Button>
) : null}
</div>
{item.annotation ? (
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
) : null}
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
</article>
))}
</div>
) : null}
</section>
))}
</div>
</Panel>
</>
)}
</>
) : null}
</AppShell>
);
}
function UploadIcon() {
return <FolderUp className="size-4" />;
}

View File

@@ -391,6 +391,14 @@ export default function WatchlistPage() {
Analyze Analyze
<ArrowRight className="size-3" /> <ArrowRight className="size-3" />
</Link> </Link>
<Link
href={`/research?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)]"
>
Research
</Link>
<Link <Link
href={`/financials?ticker=${item.ticker}`} href={`/financials?ticker=${item.ticker}`}
onMouseEnter={() => prefetchResearchTicker(item.ticker)} onMouseEnter={() => prefetchResearchTicker(item.ticker)}

View File

@@ -2,7 +2,7 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu } from 'lucide-react'; import { Activity, BarChart3, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu, NotebookTabs } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
@@ -11,6 +11,7 @@ import { TaskDetailModal } from '@/components/notifications/task-detail-modal';
import { TaskNotificationsTrigger } from '@/components/notifications/task-notifications-trigger'; import { TaskNotificationsTrigger } from '@/components/notifications/task-notifications-trigger';
import { import {
companyAnalysisQueryOptions, companyAnalysisQueryOptions,
companyFinancialStatementsQueryOptions,
filingsQueryOptions, filingsQueryOptions,
holdingsQueryOptions, holdingsQueryOptions,
latestPortfolioInsightQueryOptions, latestPortfolioInsightQueryOptions,
@@ -18,6 +19,7 @@ import {
recentTasksQueryOptions, recentTasksQueryOptions,
watchlistQueryOptions watchlistQueryOptions
} from '@/lib/query/options'; } from '@/lib/query/options';
import { buildGraphingHref } from '@/lib/graphing/catalog';
import type { ActiveContext, NavGroup, NavItem } from '@/lib/types'; import type { ActiveContext, NavGroup, NavItem } from '@/lib/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTaskNotificationsCenter } from '@/hooks/use-task-notifications-center'; import { useTaskNotificationsCenter } from '@/hooks/use-task-notifications-center';
@@ -56,6 +58,26 @@ const NAV_ITEMS: NavConfigItem[] = [
preserveTicker: true, preserveTicker: true,
mobilePrimary: true mobilePrimary: true
}, },
{
id: 'research',
href: '/research',
label: 'Research',
icon: NotebookTabs,
group: 'research',
matchMode: 'exact',
preserveTicker: true,
mobilePrimary: true
},
{
id: 'graphing',
href: '/graphing',
label: 'Graphing',
icon: BarChart3,
group: 'research',
matchMode: 'exact',
preserveTicker: true,
mobilePrimary: false
},
{ {
id: 'financials', id: 'financials',
href: '/financials', href: '/financials',
@@ -117,6 +139,10 @@ function toTickerHref(baseHref: string, activeTicker: string | null) {
} }
function resolveNavHref(item: NavItem, context: ActiveContext) { function resolveNavHref(item: NavItem, context: ActiveContext) {
if (item.href === '/graphing') {
return buildGraphingHref(context.activeTicker);
}
if (!item.preserveTicker) { if (!item.preserveTicker) {
return item.href; return item.href;
} }
@@ -134,6 +160,8 @@ function isItemActive(item: NavItem, pathname: string) {
function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null) { function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null) {
const analysisHref = toTickerHref('/analysis', activeTicker); const analysisHref = toTickerHref('/analysis', activeTicker);
const researchHref = toTickerHref('/research', activeTicker);
const graphingHref = buildGraphingHref(activeTicker);
const financialsHref = toTickerHref('/financials', activeTicker); const financialsHref = toTickerHref('/financials', activeTicker);
const filingsHref = toTickerHref('/filings', activeTicker); const filingsHref = toTickerHref('/filings', activeTicker);
@@ -153,6 +181,13 @@ function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null)
return [{ label: 'Analysis' }]; return [{ label: 'Analysis' }];
} }
if (pathname.startsWith('/research')) {
return [
{ label: 'Analysis', href: analysisHref },
{ label: 'Research', href: researchHref }
];
}
if (pathname.startsWith('/financials')) { if (pathname.startsWith('/financials')) {
return [ return [
{ label: 'Analysis', href: analysisHref }, { label: 'Analysis', href: analysisHref },
@@ -160,6 +195,14 @@ function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null)
]; ];
} }
if (pathname.startsWith('/graphing')) {
return [
{ label: 'Analysis', href: analysisHref },
{ label: 'Graphing', href: graphingHref },
{ label: activeTicker ?? 'Compare Set' }
];
}
if (pathname.startsWith('/filings')) { if (pathname.startsWith('/filings')) {
return [ return [
{ label: 'Analysis', href: analysisHref }, { label: 'Analysis', href: analysisHref },
@@ -281,6 +324,20 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
return; return;
} }
if (href.startsWith('/graphing')) {
if (context.activeTicker) {
void queryClient.prefetchQuery(companyFinancialStatementsQueryOptions({
ticker: context.activeTicker,
surfaceKind: 'income_statement',
cadence: 'annual',
includeDimensions: false,
includeFacts: false,
limit: 16
}));
}
return;
}
if (href.startsWith('/filings')) { if (href.startsWith('/filings')) {
void queryClient.prefetchQuery(filingsQueryOptions({ void queryClient.prefetchQuery(filingsQueryOptions({
ticker: context.activeTicker ?? undefined, ticker: context.activeTicker ?? undefined,
@@ -317,7 +374,7 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
}; };
const runPrefetch = () => { const runPrefetch = () => {
const prioritized = navEntries.filter((entry) => entry.id === 'analysis' || entry.id === 'filings' || entry.id === 'portfolio'); const prioritized = navEntries.filter((entry) => entry.id === 'analysis' || entry.id === 'graphing' || entry.id === 'filings' || entry.id === 'portfolio');
for (const entry of prioritized) { for (const entry of prioritized) {
prefetchForHref(entry.href); prefetchForHref(entry.href);
} }

View File

@@ -0,0 +1,91 @@
CREATE TABLE `research_artifact` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`organization_id` text,
`ticker` text NOT NULL,
`accession_number` text,
`kind` text NOT NULL,
`source` text NOT NULL DEFAULT 'user',
`subtype` text,
`title` text,
`summary` text,
`body_markdown` text,
`search_text` text,
`visibility_scope` text NOT NULL DEFAULT 'private',
`tags` text,
`metadata` text,
`file_name` text,
`mime_type` text,
`file_size_bytes` integer,
`storage_path` 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,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `research_artifact_ticker_idx` ON `research_artifact` (`user_id`,`ticker`,`updated_at`);
--> statement-breakpoint
CREATE INDEX `research_artifact_kind_idx` ON `research_artifact` (`user_id`,`kind`,`updated_at`);
--> statement-breakpoint
CREATE INDEX `research_artifact_accession_idx` ON `research_artifact` (`user_id`,`accession_number`);
--> statement-breakpoint
CREATE INDEX `research_artifact_source_idx` ON `research_artifact` (`user_id`,`source`,`updated_at`);
--> statement-breakpoint
CREATE TABLE `research_memo` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`organization_id` text,
`ticker` text NOT NULL,
`rating` text,
`conviction` text,
`time_horizon_months` integer,
`packet_title` text,
`packet_subtitle` text,
`thesis_markdown` text NOT NULL DEFAULT '',
`variant_view_markdown` text NOT NULL DEFAULT '',
`catalysts_markdown` text NOT NULL DEFAULT '',
`risks_markdown` text NOT NULL DEFAULT '',
`disconfirming_evidence_markdown` text NOT NULL DEFAULT '',
`next_actions_markdown` text NOT NULL DEFAULT '',
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `research_memo_ticker_uidx` ON `research_memo` (`user_id`,`ticker`);
--> statement-breakpoint
CREATE INDEX `research_memo_updated_idx` ON `research_memo` (`user_id`,`updated_at`);
--> statement-breakpoint
CREATE TABLE `research_memo_evidence` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`memo_id` integer NOT NULL,
`artifact_id` integer NOT NULL,
`section` text NOT NULL,
`annotation` text,
`sort_order` integer NOT NULL DEFAULT 0,
`created_at` text NOT NULL,
FOREIGN KEY (`memo_id`) REFERENCES `research_memo`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`artifact_id`) REFERENCES `research_artifact`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `research_memo_evidence_memo_idx` ON `research_memo_evidence` (`memo_id`,`section`,`sort_order`);
--> statement-breakpoint
CREATE INDEX `research_memo_evidence_artifact_idx` ON `research_memo_evidence` (`artifact_id`);
--> statement-breakpoint
CREATE UNIQUE INDEX `research_memo_evidence_unique_uidx` ON `research_memo_evidence` (`memo_id`,`artifact_id`,`section`);
--> statement-breakpoint
CREATE VIRTUAL TABLE `research_artifact_fts` USING fts5(
artifact_id UNINDEXED,
user_id UNINDEXED,
ticker UNINDEXED,
title,
summary,
body_markdown,
search_text,
tags_text
);

View File

@@ -57,6 +57,13 @@
"when": 1772863200000, "when": 1772863200000,
"tag": "0007_company_financial_bundles", "tag": "0007_company_financial_bundles",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1772906400000,
"tag": "0008_research_workspace",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,17 +1,51 @@
import { expect, test } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
const PASSWORD = 'Sup3rSecure!123'; const PASSWORD = 'Sup3rSecure!123';
test('redirects protected routes to sign in and preserves the return path', async ({ page }) => { test.describe.configure({ mode: 'serial' });
await page.goto('/analysis?ticker=nvda');
function createDeferred() {
let resolve: (() => void) | null = null;
const promise = new Promise<void>((done) => {
resolve = done;
});
return {
promise,
resolve: () => resolve?.()
};
}
async function gotoAuthPage(page: Page, path: string) {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle');
}
test('preserves the return path while switching between auth screens and shows the expected controls', async ({ page }) => {
await gotoAuthPage(page, '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA');
await expect(page).toHaveURL(/\/auth\/signin\?/);
await expect(page.getByRole('heading', { name: 'Secure Sign In' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Secure Sign In' })).toBeVisible();
expect(new URL(page.url()).searchParams.get('next')).toBe('/analysis?ticker=nvda'); await expect(page.getByText('Use email/password or request a magic link.')).toBeVisible();
await expect(page.locator('input[autocomplete="email"]')).toBeVisible();
await expect(page.locator('input[autocomplete="current-password"]')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in with password' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Send magic link' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Create one' })).toHaveAttribute('href', '/auth/signup?next=%2Fanalysis%3Fticker%3DNVDA');
await page.getByRole('link', { name: 'Create one' }).click();
await expect(page).toHaveURL(/\/auth\/signup\?next=%2Fanalysis%3Fticker%3DNVDA$/);
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
await expect(page.getByText('Set up your operator profile to access portfolio and filings intelligence.')).toBeVisible();
await expect(page.locator('input[autocomplete="name"]')).toBeVisible();
await expect(page.locator('input[autocomplete="email"]')).toBeVisible();
await expect(page.locator('input[autocomplete="new-password"]').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA');
}); });
test('shows client-side validation when signup passwords do not match', async ({ page }) => { test('shows client-side validation when signup passwords do not match', async ({ page }) => {
await page.goto('/auth/signup'); await gotoAuthPage(page, '/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright User'); await page.locator('input[autocomplete="name"]').fill('Playwright User');
await page.locator('input[autocomplete="email"]').fill('mismatch@example.com'); await page.locator('input[autocomplete="email"]').fill('mismatch@example.com');
@@ -22,17 +56,57 @@ test('shows client-side validation when signup passwords do not match', async ({
await expect(page.getByText('Passwords do not match.')).toBeVisible(); await expect(page.getByText('Passwords do not match.')).toBeVisible();
}); });
test('creates a new account and lands on the command center', async ({ page }) => { test('shows loading affordances while sign-in is in flight', async ({ page }) => {
const email = `playwright-${Date.now()}@example.com`; const gate = createDeferred();
await page.goto('/auth/signup'); await page.route('**/api/auth/sign-in/email', async (route) => {
await gate.promise;
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Invalid credentials' })
});
});
await gotoAuthPage(page, '/auth/signin');
await page.locator('input[autocomplete="email"]').fill('playwright@example.com');
await page.locator('input[autocomplete="current-password"]').fill(PASSWORD);
const submitButton = page.getByRole('button', { name: 'Sign in with password' });
const magicLinkButton = page.getByRole('button', { name: 'Send magic link' });
await submitButton.click();
await expect(page.getByRole('button', { name: 'Signing in...' })).toBeDisabled();
await expect(magicLinkButton).toBeDisabled();
gate.resolve();
await expect(page.getByText('Invalid credentials')).toBeVisible();
});
test('shows loading affordances while sign-up is in flight', async ({ page }) => {
const gate = createDeferred();
await page.route('**/api/auth/sign-up/email', async (route) => {
await gate.promise;
await route.fulfill({
status: 409,
contentType: 'application/json',
body: JSON.stringify({ message: 'Email already exists' })
});
});
await gotoAuthPage(page, '/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright User'); await page.locator('input[autocomplete="name"]').fill('Playwright User');
await page.locator('input[autocomplete="email"]').fill(email); await page.locator('input[autocomplete="email"]').fill(`playwright-${Date.now()}@example.com`);
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD); await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD); await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/$/); await expect(page.getByRole('button', { name: 'Creating account...' })).toBeDisabled();
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible();
await expect(page.getByText('Quick Links')).toBeVisible(); gate.resolve();
await expect(page.getByText('Email already exists')).toBeVisible();
}); });

View File

@@ -0,0 +1,4 @@
Supply-chain diligence notes:
- Channel checks remain constructive.
- Gross margin watchpoint is still mix-driven.
- Need follow-up on inventory normalization pace.

255
e2e/graphing.spec.ts Normal file
View File

@@ -0,0 +1,255 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
const PASSWORD = 'Sup3rSecure!123';
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-graphing-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
await page.goto('/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright Graphing 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.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
}
function createFinancialsPayload(input: {
ticker: string;
companyName: string;
cadence: 'annual' | 'quarterly' | 'ltm';
surface: string;
}) {
return {
financials: {
company: {
ticker: input.ticker,
companyName: input.companyName,
cik: null
},
surfaceKind: input.surface,
cadence: input.cadence,
displayModes: ['standardized'],
defaultDisplayMode: 'standardized',
periods: [
{
id: `${input.ticker}-p1`,
filingId: 1,
accessionNumber: `0000-${input.ticker}-1`,
filingDate: '2025-02-01',
periodStart: '2024-01-01',
periodEnd: '2024-12-31',
filingType: '10-K',
periodLabel: 'FY 2024'
},
{
id: `${input.ticker}-p2`,
filingId: 2,
accessionNumber: `0000-${input.ticker}-2`,
filingDate: '2026-02-01',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
filingType: '10-K',
periodLabel: 'FY 2025'
}
],
statementRows: {
faithful: [],
standardized: [
{
key: 'revenue',
label: 'Revenue',
category: 'revenue',
order: 10,
unit: 'currency',
values: {
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 320 : 280,
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 360 : 330
},
sourceConcepts: ['revenue'],
sourceRowKeys: ['revenue'],
sourceFactIds: [1],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'revenue',
[`${input.ticker}-p2`]: 'revenue'
}
},
{
key: 'total_assets',
label: 'Total Assets',
category: 'asset',
order: 20,
unit: 'currency',
values: {
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 410 : 380,
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 450 : 420
},
sourceConcepts: ['total_assets'],
sourceRowKeys: ['total_assets'],
sourceFactIds: [2],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'total_assets',
[`${input.ticker}-p2`]: 'total_assets'
}
},
{
key: 'free_cash_flow',
label: 'Free Cash Flow',
category: 'cash_flow',
order: 30,
unit: 'currency',
values: {
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 95 : 80,
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 105 : 92
},
sourceConcepts: ['free_cash_flow'],
sourceRowKeys: ['free_cash_flow'],
sourceFactIds: [3],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'free_cash_flow',
[`${input.ticker}-p2`]: 'free_cash_flow'
}
}
]
},
ratioRows: [
{
key: 'gross_margin',
label: 'Gross Margin',
category: 'margins',
order: 10,
unit: 'percent',
values: {
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 0.43 : 0.39,
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 0.45 : 0.41
},
sourceConcepts: ['gross_margin'],
sourceRowKeys: ['gross_margin'],
sourceFactIds: [4],
formulaKey: 'gross_margin',
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: null,
[`${input.ticker}-p2`]: null
},
denominatorKey: 'revenue'
}
],
kpiRows: null,
trendSeries: [],
categories: [],
availability: {
adjusted: false,
customMetrics: false
},
nextCursor: null,
facts: null,
coverage: {
filings: 2,
rows: 3,
dimensions: 0,
facts: 0
},
dataSourceStatus: {
enabled: true,
hydratedFilings: 2,
partialFilings: 0,
failedFilings: 0,
pendingFilings: 0,
queuedSync: false
},
metrics: {
taxonomy: null,
validation: null
},
dimensionBreakdown: null
}
};
}
async function mockGraphingFinancials(page: Page) {
await page.route('**/api/financials/company**', async (route) => {
const url = new URL(route.request().url());
const ticker = url.searchParams.get('ticker') ?? 'MSFT';
const cadence = (url.searchParams.get('cadence') ?? 'annual') as 'annual' | 'quarterly' | 'ltm';
const surface = url.searchParams.get('surface') ?? 'income_statement';
if (ticker === 'BAD') {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Ticker not found' })
});
return;
}
const companyName = ticker === 'AAPL'
? 'Apple Inc.'
: ticker === 'NVDA'
? 'NVIDIA Corporation'
: ticker === 'AMD'
? 'Advanced Micro Devices, Inc.'
: 'Microsoft Corporation';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(createFinancialsPayload({
ticker,
companyName,
cadence,
surface
}))
});
});
}
test('supports graphing compare controls and partial failures', async ({ page }, testInfo) => {
await signUp(page, testInfo);
await mockGraphingFinancials(page);
await page.goto('/graphing');
await expect(page).toHaveURL(/tickers=MSFT%2CAAPL%2CNVDA/);
await expect(page.getByRole('heading', { name: 'Graphing' })).toBeVisible();
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
await page.getByRole('button', { name: 'Graph surface Balance Sheet' }).click();
await expect(page).toHaveURL(/surface=balance_sheet/);
await expect(page).toHaveURL(/metric=total_assets/);
await page.getByRole('button', { name: 'Graph cadence Quarterly' }).click();
await expect(page).toHaveURL(/cadence=quarterly/);
await page.getByRole('button', { name: 'Chart type Bar' }).click();
await expect(page).toHaveURL(/chart=bar/);
await page.getByRole('button', { name: 'Remove AAPL' }).click();
await expect(page).not.toHaveURL(/AAPL/);
await page.getByLabel('Compare tickers').fill('MSFT, NVDA, AMD');
await page.getByRole('button', { name: 'Update Compare Set' }).click();
await expect(page).toHaveURL(/tickers=MSFT%2CNVDA%2CAMD/);
await expect(page.getByText('Advanced Micro Devices, Inc.')).toBeVisible();
await page.goto('/graphing?tickers=MSFT,BAD&surface=income_statement&metric=revenue&cadence=annual&chart=line&scale=millions');
await expect(page.getByText('Partial coverage detected.')).toBeVisible();
await expect(page.getByRole('cell', { name: /BAD/ })).toBeVisible();
await expect(page.getByText('Ticker not found')).toBeVisible();
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
});

View File

@@ -17,14 +17,59 @@ function toSlug(value: string) {
async function signUp(page: Page, testInfo: TestInfo) { async function signUp(page: Page, testInfo: TestInfo) {
const email = `playwright-research-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`; const email = `playwright-research-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3400';
const output = execFileSync('bun', [
'-e',
`
const [email, password] = process.argv.slice(1);
const { auth } = await import('./lib/auth');
const response = await auth.api.signUpEmail({
body: {
name: 'Playwright Research User',
email,
password,
callbackURL: '/'
},
asResponse: true
});
await page.goto('/auth/signup'); console.log(JSON.stringify({
await page.locator('input[autocomplete="name"]').fill('Playwright Research User'); status: response.status,
await page.locator('input[autocomplete="email"]').fill(email); sessionCookie: response.headers.get('set-cookie')
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(); email,
PASSWORD
], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: `file:${E2E_DATABASE_PATH}`,
BETTER_AUTH_BASE_URL: baseURL,
BETTER_AUTH_SECRET: 'playwright-e2e-secret-playwright-e2e-secret',
BETTER_AUTH_TRUSTED_ORIGINS: baseURL
},
encoding: 'utf8'
});
const { status, sessionCookie } = JSON.parse(output) as { status: number; sessionCookie: string | null };
expect(status).toBe(200);
expect(sessionCookie).toBeTruthy();
const [cookieNameValue] = sessionCookie!.split(';');
const separatorIndex = cookieNameValue!.indexOf('=');
const cookieName = cookieNameValue!.slice(0, separatorIndex);
const cookieValue = cookieNameValue!.slice(separatorIndex + 1);
await page.context().addCookies([{
name: cookieName,
value: cookieValue,
url: baseURL,
httpOnly: true,
sameSite: 'Lax'
}]);
await page.goto('/');
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/$/);
return email; return email;
} }
@@ -109,7 +154,9 @@ finally:
} }
test('supports the core coverage-to-research workflow', async ({ page }, testInfo) => { test('supports the core coverage-to-research workflow', async ({ page }, testInfo) => {
test.slow();
const accessionNumber = `0001045810-26-${String(Date.now()).slice(-6)}`; const accessionNumber = `0001045810-26-${String(Date.now()).slice(-6)}`;
const uploadFixture = join(process.cwd(), 'e2e', 'fixtures', 'sample-research.txt');
await signUp(page, testInfo); await signUp(page, testInfo);
seedFiling({ seedFiling({
@@ -139,30 +186,61 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/); await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
await expect(page.getByText('Coverage Workflow')).toBeVisible(); await expect(page.getByText('Coverage Workflow')).toBeVisible();
await page.getByLabel('Journal title').fill('Own-the-stack moat check'); await page.getByRole('link', { name: 'Open research' }).click();
await page.getByLabel('Journal body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.'); await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
await page.getByLabel('Research note title').fill('Own-the-stack moat check');
await page.getByLabel('Research note summary').fill('Initial moat checkpoint');
await page.getByLabel('Research note body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.');
await page.getByLabel('Research note tags').fill('moat, thesis');
await page.getByRole('button', { name: 'Save note' }).click(); await page.getByRole('button', { name: 'Save note' }).click();
await expect(page.getByText('Own-the-stack moat check')).toBeVisible(); await expect(page.getByText('Saved note to the research library.')).toBeVisible();
await page.getByLabel('Upload title').fill('Supply-chain diligence');
await page.getByLabel('Upload summary').fill('Vendor and channel-check notes');
await page.getByLabel('Upload tags').fill('diligence, channel-check');
await page.getByLabel('Upload file').setInputFiles(uploadFixture);
await page.locator('button', { hasText: 'Upload file' }).click();
await expect(page.getByText('Uploaded research file.')).toBeVisible();
await page.goto(`/analysis?ticker=NVDA`);
await page.getByRole('link', { name: 'Open summary' }).first().click(); await page.getByRole('link', { name: 'Open summary' }).first().click();
await expect(page).toHaveURL(/\/analysis\/reports\/NVDA\//); await expect(page).toHaveURL(/\/analysis\/reports\/NVDA\//);
await page.getByRole('button', { name: 'Add to journal' }).click(); await page.getByRole('button', { name: 'Save to library' }).click();
await expect(page.getByText('Saved to the company research journal.')).toBeVisible(); await expect(page.getByText('Saved to the company research library.')).toBeVisible();
await page.getByRole('link', { name: 'Back to analysis' }).click(); await page.goto('/research?ticker=NVDA');
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
await expect(page.getByRole('heading', { name: '10-K AI memo' }).first()).toBeVisible();
await page.getByLabel('Memo rating').selectOption('buy');
await page.getByLabel('Memo conviction').selectOption('high');
await page.getByLabel('Memo time horizon').fill('24');
await page.getByLabel('Packet title').fill('NVIDIA buy-side packet');
await page.getByLabel('Packet subtitle').fill('AI infrastructure compounder');
await page.getByLabel('Memo Thesis').fill('Maintain a constructive stance as datacenter demand and platform depth widen the moat.');
await page.getByLabel('Memo Catalysts').fill('Blackwell ramp, enterprise inference demand, and sustained operating leverage.');
await page.getByLabel('Memo Risks').fill('Customer concentration, competition, and execution on supply.');
await page.getByRole('button', { name: 'Save memo' }).click();
await expect(page.getByText('Saved investment memo.')).toBeVisible();
await page.getByRole('button', { name: 'Attach' }).first().click();
await expect(page.getByText('Attached evidence to Thesis.')).toBeVisible();
await page.goto('/analysis?ticker=NVDA');
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/); 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 page.goto('/financials?ticker=NVDA');
await expect(page).toHaveURL(/\/financials\?ticker=NVDA/); await expect(page).toHaveURL(/\/financials\?ticker=NVDA/);
await page.getByRole('link', { name: 'Filings' }).first().click(); await page.goto('/filings?ticker=NVDA');
await expect(page).toHaveURL(/\/filings\?ticker=NVDA/); await expect(page).toHaveURL(/\/filings\?ticker=NVDA/);
await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible(); await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
await expect(page.getByRole('button', { name: /journal/i }).first()).toBeVisible(); await expect(page.getByRole('link', { name: 'Summary' }).first()).toBeVisible();
}); });
test('supports add, edit, and delete holding flows with summary refresh', async ({ page }, testInfo) => { test('supports add, edit, and delete holding flows with summary refresh', async ({ page }, testInfo) => {
test.slow();
await signUp(page, testInfo); await signUp(page, testInfo);
await page.goto('/portfolio'); await page.goto('/portfolio');

View File

@@ -3,6 +3,7 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { buildGraphingHref } from '@/lib/graphing/catalog';
import { import {
aiReportQueryOptions, aiReportQueryOptions,
companyAnalysisQueryOptions, companyAnalysisQueryOptions,
@@ -12,6 +13,7 @@ import {
latestPortfolioInsightQueryOptions, latestPortfolioInsightQueryOptions,
portfolioSummaryQueryOptions, portfolioSummaryQueryOptions,
recentTasksQueryOptions, recentTasksQueryOptions,
researchWorkspaceQueryOptions,
watchlistQueryOptions watchlistQueryOptions
} from '@/lib/query/options'; } from '@/lib/query/options';
@@ -30,14 +32,19 @@ export function useLinkPrefetch() {
} }
const analysisHref = `/analysis?ticker=${encodeURIComponent(normalizedTicker)}`; const analysisHref = `/analysis?ticker=${encodeURIComponent(normalizedTicker)}`;
const researchHref = `/research?ticker=${encodeURIComponent(normalizedTicker)}`;
const filingsHref = `/filings?ticker=${encodeURIComponent(normalizedTicker)}`; const filingsHref = `/filings?ticker=${encodeURIComponent(normalizedTicker)}`;
const financialsHref = `/financials?ticker=${encodeURIComponent(normalizedTicker)}`; const financialsHref = `/financials?ticker=${encodeURIComponent(normalizedTicker)}`;
const graphingHref = buildGraphingHref(normalizedTicker);
router.prefetch(analysisHref); router.prefetch(analysisHref);
router.prefetch(researchHref);
router.prefetch(filingsHref); router.prefetch(filingsHref);
router.prefetch(financialsHref); router.prefetch(financialsHref);
router.prefetch(graphingHref);
void queryClient.prefetchQuery(companyAnalysisQueryOptions(normalizedTicker)); void queryClient.prefetchQuery(companyAnalysisQueryOptions(normalizedTicker));
void queryClient.prefetchQuery(researchWorkspaceQueryOptions(normalizedTicker));
void queryClient.prefetchQuery(companyFinancialStatementsQueryOptions({ void queryClient.prefetchQuery(companyFinancialStatementsQueryOptions({
ticker: normalizedTicker, ticker: normalizedTicker,
surfaceKind: 'income_statement', surfaceKind: 'income_statement',

View File

@@ -12,8 +12,19 @@ import type {
Holding, Holding,
PortfolioInsight, PortfolioInsight,
PortfolioSummary, PortfolioSummary,
ResearchArtifact,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchJournalEntry, ResearchJournalEntry,
ResearchJournalEntryType, ResearchJournalEntryType,
ResearchLibraryResponse,
ResearchMemo,
ResearchMemoConviction,
ResearchMemoEvidenceLink,
ResearchMemoRating,
ResearchMemoSection,
ResearchPacket,
ResearchWorkspace,
Task, Task,
TaskStatus, TaskStatus,
TaskTimeline, TaskTimeline,
@@ -105,7 +116,7 @@ async function unwrapData<T>(result: TreatyResult, fallback: string) {
async function requestJson<T>(input: { async function requestJson<T>(input: {
path: string; path: string;
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown; body?: unknown;
}, fallback: string) { }, fallback: string) {
const response = await fetch(`${API_BASE}${input.path}`, { const response = await fetch(`${API_BASE}${input.path}`, {
@@ -206,6 +217,208 @@ export async function createResearchJournalEntry(input: {
}, 'Unable to create journal entry'); }, 'Unable to create journal entry');
} }
export async function getResearchWorkspace(ticker: string) {
return await requestJson<{ workspace: ResearchWorkspace }>({
path: `/api/research/workspace?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
}, 'Unable to fetch research workspace');
}
export async function listResearchLibrary(input: {
ticker: string;
q?: string;
kind?: ResearchArtifactKind;
tag?: string;
source?: ResearchArtifactSource;
linkedToMemo?: boolean;
limit?: number;
}) {
const params = new URLSearchParams({
ticker: input.ticker.trim().toUpperCase()
});
if (input.q?.trim()) {
params.set('q', input.q.trim());
}
if (input.kind) {
params.set('kind', input.kind);
}
if (input.tag?.trim()) {
params.set('tag', input.tag.trim());
}
if (input.source) {
params.set('source', input.source);
}
if (input.linkedToMemo !== undefined) {
params.set('linkedToMemo', input.linkedToMemo ? 'true' : 'false');
}
if (typeof input.limit === 'number') {
params.set('limit', String(input.limit));
}
return await requestJson<ResearchLibraryResponse>({
path: `/api/research/library?${params.toString()}`
}, 'Unable to fetch research library');
}
export async function createResearchArtifact(input: {
ticker: string;
accessionNumber?: string;
kind: ResearchArtifactKind;
source?: ResearchArtifactSource;
subtype?: string;
title?: string;
summary?: string;
bodyMarkdown?: string;
tags?: string[];
metadata?: Record<string, unknown>;
}) {
return await requestJson<{ artifact: ResearchArtifact }>({
path: '/api/research/library',
method: 'POST',
body: {
...input,
ticker: input.ticker.trim().toUpperCase()
}
}, 'Unable to create research artifact');
}
export async function updateResearchArtifact(id: number, input: {
title?: string;
summary?: string;
bodyMarkdown?: string;
tags?: string[];
metadata?: Record<string, unknown>;
}) {
return await requestJson<{ artifact: ResearchArtifact }>({
path: `/api/research/library/${id}`,
method: 'PATCH',
body: input
}, 'Unable to update research artifact');
}
export async function deleteResearchArtifact(id: number) {
return await requestJson<{ success: boolean }>({
path: `/api/research/library/${id}`,
method: 'DELETE'
}, 'Unable to delete research artifact');
}
export async function uploadResearchArtifact(input: {
ticker: string;
file: File;
title?: string;
summary?: string;
tags?: string[];
}) {
const form = new FormData();
form.set('ticker', input.ticker.trim().toUpperCase());
form.set('file', input.file);
if (input.title?.trim()) {
form.set('title', input.title.trim());
}
if (input.summary?.trim()) {
form.set('summary', input.summary.trim());
}
if (input.tags && input.tags.length > 0) {
form.set('tags', input.tags.join(','));
}
const response = await fetch(`${API_BASE}/api/research/library/upload`, {
method: 'POST',
credentials: 'include',
cache: 'no-store',
body: form
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new ApiError(
extractErrorMessage({ value: payload }, 'Unable to upload research file'),
response.status
);
}
if (!payload) {
throw new ApiError('Unable to upload research file', response.status);
}
return payload as { artifact: ResearchArtifact };
}
export function getResearchArtifactFileUrl(id: number) {
return `${API_BASE}/api/research/library/${id}/file`;
}
export async function getResearchMemo(ticker: string) {
return await requestJson<{ memo: ResearchMemo | null }>({
path: `/api/research/memo?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
}, 'Unable to fetch research memo');
}
export async function upsertResearchMemo(input: {
ticker: string;
rating?: ResearchMemoRating | null;
conviction?: ResearchMemoConviction | null;
timeHorizonMonths?: number | null;
packetTitle?: string;
packetSubtitle?: string;
thesisMarkdown?: string;
variantViewMarkdown?: string;
catalystsMarkdown?: string;
risksMarkdown?: string;
disconfirmingEvidenceMarkdown?: string;
nextActionsMarkdown?: string;
}) {
return await requestJson<{ memo: ResearchMemo }>({
path: '/api/research/memo',
method: 'PUT',
body: {
...input,
ticker: input.ticker.trim().toUpperCase()
}
}, 'Unable to save research memo');
}
export async function addResearchMemoEvidence(input: {
memoId: number;
artifactId: number;
section: ResearchMemoSection;
annotation?: string;
sortOrder?: number;
}) {
return await requestJson<{ evidence: ResearchMemoEvidenceLink[] }>({
path: `/api/research/memo/${input.memoId}/evidence`,
method: 'POST',
body: {
artifactId: input.artifactId,
section: input.section,
annotation: input.annotation,
sortOrder: input.sortOrder
}
}, 'Unable to attach memo evidence');
}
export async function deleteResearchMemoEvidence(memoId: number, linkId: number) {
return await requestJson<{ success: boolean }>({
path: `/api/research/memo/${memoId}/evidence/${linkId}`,
method: 'DELETE'
}, 'Unable to delete memo evidence');
}
export async function getResearchPacket(ticker: string) {
return await requestJson<{ packet: ResearchPacket }>({
path: `/api/research/packet?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
}, 'Unable to fetch research packet');
}
export async function updateResearchJournalEntry(id: number, input: { export async function updateResearchJournalEntry(id: number, input: {
title?: string; title?: string;
bodyMarkdown?: string; bodyMarkdown?: string;

147
lib/financial-metrics.ts Normal file
View File

@@ -0,0 +1,147 @@
import type {
FinancialCadence,
FinancialSurfaceKind,
FinancialUnit,
RatioRow
} from '@/lib/types';
export type GraphableFinancialSurfaceKind = Extract<
FinancialSurfaceKind,
'income_statement' | 'balance_sheet' | 'cash_flow_statement' | 'ratios'
>;
export type StatementMetricDefinition = {
key: string;
label: string;
category: string;
order: number;
unit: FinancialUnit;
localNames?: readonly string[];
labelIncludes?: readonly string[];
};
export type RatioMetricDefinition = {
key: string;
label: string;
category: string;
order: number;
unit: RatioRow['unit'];
denominatorKey: string | null;
supportedCadences?: readonly FinancialCadence[];
};
export const GRAPHABLE_FINANCIAL_SURFACES: readonly GraphableFinancialSurfaceKind[] = [
'income_statement',
'balance_sheet',
'cash_flow_statement',
'ratios'
] as const;
export const INCOME_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
{ key: 'revenue', label: 'Revenue', category: 'revenue', order: 10, unit: 'currency', localNames: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'TotalRevenuesAndOtherIncome'], labelIncludes: ['revenue', 'sales'] },
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
] as const satisfies StatementMetricDefinition[];
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
{ key: 'cash_and_equivalents', label: 'Cash & Equivalents', category: 'asset', order: 10, unit: 'currency', localNames: ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsAndShortTermInvestments', 'CashAndShortTermInvestments'], labelIncludes: ['cash and cash equivalents'] },
{ key: 'short_term_investments', label: 'Short-Term Investments', category: 'asset', order: 20, unit: 'currency', localNames: ['AvailableForSaleSecuritiesCurrent', 'ShortTermInvestments'], labelIncludes: ['short-term investments', 'marketable securities'] },
{ key: 'accounts_receivable', label: 'Accounts Receivable', category: 'asset', order: 30, unit: 'currency', localNames: ['AccountsReceivableNetCurrent', 'ReceivablesNetCurrent'], labelIncludes: ['accounts receivable'] },
{ key: 'inventory', label: 'Inventory', category: 'asset', order: 40, unit: 'currency', localNames: ['InventoryNet'], labelIncludes: ['inventory'] },
{ key: 'other_current_assets', label: 'Other Current Assets', category: 'asset', order: 50, unit: 'currency', localNames: ['OtherAssetsCurrent'], labelIncludes: ['other current assets'] },
{ key: 'current_assets', label: 'Current Assets', category: 'asset', order: 60, unit: 'currency', localNames: ['AssetsCurrent'], labelIncludes: ['current assets'] },
{ key: 'property_plant_equipment', label: 'Property, Plant & Equipment', category: 'asset', order: 70, unit: 'currency', localNames: ['PropertyPlantAndEquipmentNet'], labelIncludes: ['property, plant and equipment', 'property and equipment'] },
{ key: 'goodwill', label: 'Goodwill', category: 'asset', order: 80, unit: 'currency', localNames: ['Goodwill'], labelIncludes: ['goodwill'] },
{ key: 'intangible_assets', label: 'Intangible Assets', category: 'asset', order: 90, unit: 'currency', localNames: ['FiniteLivedIntangibleAssetsNet', 'IndefiniteLivedIntangibleAssetsExcludingGoodwill', 'IntangibleAssetsNetExcludingGoodwill'], labelIncludes: ['intangible assets'] },
{ key: 'total_assets', label: 'Total Assets', category: 'asset', order: 100, unit: 'currency', localNames: ['Assets'], labelIncludes: ['total assets'] },
{ key: 'accounts_payable', label: 'Accounts Payable', category: 'liability', order: 110, unit: 'currency', localNames: ['AccountsPayableCurrent'], labelIncludes: ['accounts payable'] },
{ key: 'accrued_liabilities', label: 'Accrued Liabilities', category: 'liability', order: 120, unit: 'currency', localNames: ['AccruedLiabilitiesCurrent'], labelIncludes: ['accrued liabilities'] },
{ key: 'deferred_revenue_current', label: 'Deferred Revenue, Current', category: 'liability', order: 130, unit: 'currency', localNames: ['ContractWithCustomerLiabilityCurrent', 'DeferredRevenueCurrent'], labelIncludes: ['deferred revenue current', 'current deferred revenue'] },
{ key: 'current_liabilities', label: 'Current Liabilities', category: 'liability', order: 140, unit: 'currency', localNames: ['LiabilitiesCurrent'], labelIncludes: ['current liabilities'] },
{ key: 'long_term_debt', label: 'Long-Term Debt', category: 'liability', order: 150, unit: 'currency', localNames: ['LongTermDebtNoncurrent', 'LongTermDebt', 'DebtNoncurrent', 'LongTermDebtAndCapitalLeaseObligations'], labelIncludes: ['long-term debt'] },
{ key: 'current_debt', label: 'Current Debt', category: 'liability', order: 160, unit: 'currency', localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent'], labelIncludes: ['current debt', 'short-term debt'] },
{ key: 'lease_liabilities', label: 'Lease Liabilities', category: 'liability', order: 170, unit: 'currency', localNames: ['OperatingLeaseLiabilityNoncurrent', 'FinanceLeaseLiabilityNoncurrent', 'LesseeOperatingLeaseLiability'], labelIncludes: ['lease liabilities'] },
{ key: 'total_debt', label: 'Total Debt', category: 'liability', order: 180, unit: 'currency', localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'], labelIncludes: ['total debt'] },
{ key: 'deferred_revenue_noncurrent', label: 'Deferred Revenue, Noncurrent', category: 'liability', order: 190, unit: 'currency', localNames: ['ContractWithCustomerLiabilityNoncurrent', 'DeferredRevenueNoncurrent'], labelIncludes: ['deferred revenue noncurrent'] },
{ key: 'total_liabilities', label: 'Total Liabilities', category: 'liability', order: 200, unit: 'currency', localNames: ['Liabilities'], labelIncludes: ['total liabilities'] },
{ key: 'retained_earnings', label: 'Retained Earnings', category: 'equity', order: 210, unit: 'currency', localNames: ['RetainedEarningsAccumulatedDeficit'], labelIncludes: ['retained earnings', 'accumulated deficit'] },
{ key: 'total_equity', label: 'Total Equity', category: 'equity', order: 220, unit: 'currency', localNames: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'PartnersCapital'], labelIncludes: ['total equity', 'stockholders equity', 'stockholders equity'] },
{ key: 'net_cash_position', label: 'Net Cash Position', category: 'liquidity', order: 230, unit: 'currency', labelIncludes: ['net cash position'] }
] as const satisfies StatementMetricDefinition[];
export const CASH_FLOW_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
{ key: 'operating_cash_flow', label: 'Operating Cash Flow', category: 'cash_flow', order: 10, unit: 'currency', localNames: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'], labelIncludes: ['operating cash flow'] },
{ key: 'capital_expenditures', label: 'Capital Expenditures', category: 'cash_flow', order: 20, unit: 'currency', localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid'], labelIncludes: ['capital expenditures', 'capital expenditure'] },
{ key: 'free_cash_flow', label: 'Free Cash Flow', category: 'cash_flow', order: 30, unit: 'currency', labelIncludes: ['free cash flow'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 40, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] },
{ key: 'acquisitions', label: 'Acquisitions', category: 'investing', order: 50, unit: 'currency', localNames: ['PaymentsToAcquireBusinessesNetOfCashAcquired'], labelIncludes: ['acquisitions'] },
{ key: 'share_repurchases', label: 'Share Repurchases', category: 'financing', order: 60, unit: 'currency', localNames: ['PaymentsForRepurchaseOfCommonStock', 'PaymentsForRepurchaseOfEquity'], labelIncludes: ['share repurchases', 'repurchase of common stock'] },
{ key: 'dividends_paid', label: 'Dividends Paid', category: 'financing', order: 70, unit: 'currency', localNames: ['PaymentsOfDividends', 'PaymentsOfDividendsCommonStock'], labelIncludes: ['dividends paid'] },
{ key: 'debt_issued', label: 'Debt Issued', category: 'financing', order: 80, unit: 'currency', localNames: ['ProceedsFromIssuanceOfLongTermDebt'], labelIncludes: ['debt issued'] },
{ key: 'debt_repaid', label: 'Debt Repaid', category: 'financing', order: 90, unit: 'currency', localNames: ['RepaymentsOfLongTermDebt', 'RepaymentsOfDebt'], labelIncludes: ['debt repaid', 'repayment of debt'] }
] as const satisfies StatementMetricDefinition[];
export const RATIO_DEFINITIONS: RatioMetricDefinition[] = [
{ key: 'gross_margin', label: 'Gross Margin', category: 'margins', order: 10, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margins', order: 20, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'ebitda_margin', label: 'EBITDA Margin', category: 'margins', order: 30, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'net_margin', label: 'Net Margin', category: 'margins', order: 40, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'fcf_margin', label: 'FCF Margin', category: 'margins', order: 50, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'roa', label: 'ROA', category: 'returns', order: 60, unit: 'percent', denominatorKey: 'total_assets' },
{ key: 'roe', label: 'ROE', category: 'returns', order: 70, unit: 'percent', denominatorKey: 'total_equity' },
{ key: 'roic', label: 'ROIC', category: 'returns', order: 80, unit: 'percent', denominatorKey: 'average_invested_capital' },
{ key: 'roce', label: 'ROCE', category: 'returns', order: 90, unit: 'percent', denominatorKey: 'average_capital_employed' },
{ key: 'debt_to_equity', label: 'Debt to Equity', category: 'financial_health', order: 100, unit: 'ratio', denominatorKey: 'total_equity' },
{ key: 'net_debt_to_ebitda', label: 'Net Debt to EBITDA', category: 'financial_health', order: 110, unit: 'ratio', denominatorKey: 'ebitda' },
{ key: 'cash_to_debt', label: 'Cash to Debt', category: 'financial_health', order: 120, unit: 'ratio', denominatorKey: 'total_debt' },
{ key: 'current_ratio', label: 'Current Ratio', category: 'financial_health', order: 130, unit: 'ratio', denominatorKey: 'current_liabilities' },
{ key: 'revenue_per_share', label: 'Revenue per Share', category: 'per_share', order: 140, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'fcf_per_share', label: 'FCF per Share', category: 'per_share', order: 150, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'book_value_per_share', label: 'Book Value per Share', category: 'per_share', order: 160, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'revenue_yoy', label: 'Revenue YoY', category: 'growth', order: 170, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'net_income_yoy', label: 'Net Income YoY', category: 'growth', order: 180, unit: 'percent', denominatorKey: 'net_income' },
{ key: 'eps_yoy', label: 'EPS YoY', category: 'growth', order: 190, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: 'fcf_yoy', label: 'FCF YoY', category: 'growth', order: 200, unit: 'percent', denominatorKey: 'free_cash_flow' },
{ key: '3y_revenue_cagr', label: '3Y Revenue CAGR', category: 'growth', order: 210, unit: 'percent', denominatorKey: 'revenue', supportedCadences: ['annual'] },
{ key: '5y_revenue_cagr', label: '5Y Revenue CAGR', category: 'growth', order: 220, unit: 'percent', denominatorKey: 'revenue', supportedCadences: ['annual'] },
{ key: '3y_eps_cagr', label: '3Y EPS CAGR', category: 'growth', order: 230, unit: 'percent', denominatorKey: 'diluted_eps', supportedCadences: ['annual'] },
{ key: '5y_eps_cagr', label: '5Y EPS CAGR', category: 'growth', order: 240, unit: 'percent', denominatorKey: 'diluted_eps', supportedCadences: ['annual'] },
{ key: 'market_cap', label: 'Market Cap', category: 'valuation', order: 250, unit: 'currency', denominatorKey: null },
{ key: 'enterprise_value', label: 'Enterprise Value', category: 'valuation', order: 260, unit: 'currency', denominatorKey: null },
{ key: 'price_to_earnings', label: 'Price to Earnings', category: 'valuation', order: 270, unit: 'ratio', denominatorKey: 'diluted_eps' },
{ key: 'price_to_fcf', label: 'Price to FCF', category: 'valuation', order: 280, unit: 'ratio', denominatorKey: 'free_cash_flow' },
{ key: 'price_to_book', label: 'Price to Book', category: 'valuation', order: 290, unit: 'ratio', denominatorKey: 'total_equity' },
{ key: 'ev_to_sales', label: 'EV to Sales', category: 'valuation', order: 300, unit: 'ratio', denominatorKey: 'revenue' },
{ key: 'ev_to_ebitda', label: 'EV to EBITDA', category: 'valuation', order: 310, unit: 'ratio', denominatorKey: 'ebitda' },
{ key: 'ev_to_fcf', label: 'EV to FCF', category: 'valuation', order: 320, unit: 'ratio', denominatorKey: 'free_cash_flow' }
] as const satisfies RatioMetricDefinition[];
export const RATIO_CATEGORY_ORDER = [
'margins',
'returns',
'financial_health',
'per_share',
'growth',
'valuation'
] as const;

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'bun:test';
import {
DEFAULT_GRAPH_TICKERS,
buildGraphingHref,
metricsForSurfaceAndCadence,
normalizeGraphTickers,
parseGraphingParams
} from '@/lib/graphing/catalog';
describe('graphing catalog', () => {
it('normalizes compare set tickers with dedupe and max five', () => {
expect(normalizeGraphTickers(' msft, aapl, msft, nvda, amd, goog, meta ')).toEqual([
'MSFT',
'AAPL',
'NVDA',
'AMD',
'GOOG'
]);
});
it('falls back to defaults when params are missing or invalid', () => {
const state = parseGraphingParams(new URLSearchParams('surface=invalid&metric=made_up&chart=nope'));
expect(state.tickers).toEqual([...DEFAULT_GRAPH_TICKERS]);
expect(state.surface).toBe('income_statement');
expect(state.metric).toBe('revenue');
expect(state.chart).toBe('line');
expect(state.scale).toBe('millions');
});
it('filters annual-only ratio metrics for non-annual views', () => {
const quarterlyMetricKeys = metricsForSurfaceAndCadence('ratios', 'quarterly').map((metric) => metric.key);
expect(quarterlyMetricKeys).not.toContain('3y_revenue_cagr');
expect(quarterlyMetricKeys).not.toContain('5y_eps_cagr');
expect(quarterlyMetricKeys).toContain('gross_margin');
});
it('replaces invalid metrics after surface and cadence normalization', () => {
const state = parseGraphingParams(new URLSearchParams('surface=ratios&cadence=quarterly&metric=5y_revenue_cagr&tickers=msft,aapl'));
expect(state.surface).toBe('ratios');
expect(state.cadence).toBe('quarterly');
expect(state.metric).toBe('gross_margin');
expect(state.tickers).toEqual(['MSFT', 'AAPL']);
});
it('builds graphing hrefs with the primary ticker leading the compare set', () => {
expect(buildGraphingHref('amd')).toContain('tickers=AMD%2CMSFT%2CAAPL%2CNVDA');
});
});

238
lib/graphing/catalog.ts Normal file
View File

@@ -0,0 +1,238 @@
import type {
FinancialCadence,
FinancialUnit,
NumberScaleUnit
} from '@/lib/types';
import {
BALANCE_SHEET_METRIC_DEFINITIONS,
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
GRAPHABLE_FINANCIAL_SURFACES,
type GraphableFinancialSurfaceKind,
INCOME_STATEMENT_METRIC_DEFINITIONS,
RATIO_DEFINITIONS
} from '@/lib/financial-metrics';
type SearchParamsLike = {
get(name: string): string | null;
};
export type GraphChartKind = 'line' | 'bar';
export type GraphMetricDefinition = {
surface: GraphableFinancialSurfaceKind;
key: string;
label: string;
category: string;
order: number;
unit: FinancialUnit;
supportedCadences: readonly FinancialCadence[];
};
export type GraphingUrlState = {
tickers: string[];
surface: GraphableFinancialSurfaceKind;
metric: string;
cadence: FinancialCadence;
chart: GraphChartKind;
scale: NumberScaleUnit;
};
export const DEFAULT_GRAPH_TICKERS = ['MSFT', 'AAPL', 'NVDA'] as const;
export const DEFAULT_GRAPH_SURFACE: GraphableFinancialSurfaceKind = 'income_statement';
export const DEFAULT_GRAPH_CADENCE: FinancialCadence = 'annual';
export const DEFAULT_GRAPH_CHART: GraphChartKind = 'line';
export const DEFAULT_GRAPH_SCALE: NumberScaleUnit = 'millions';
export const GRAPH_SURFACE_LABELS: Record<GraphableFinancialSurfaceKind, string> = {
income_statement: 'Income Statement',
balance_sheet: 'Balance Sheet',
cash_flow_statement: 'Cash Flow Statement',
ratios: 'Ratios'
};
export const GRAPH_CADENCE_OPTIONS: Array<{ value: FinancialCadence; label: string }> = [
{ value: 'annual', label: 'Annual' },
{ value: 'quarterly', label: 'Quarterly' },
{ value: 'ltm', label: 'LTM' }
];
export const GRAPH_CHART_OPTIONS: Array<{ value: GraphChartKind; label: string }> = [
{ value: 'line', label: 'Line' },
{ value: 'bar', label: 'Bar' }
];
export const GRAPH_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
{ value: 'thousands', label: 'Thousands (K)' },
{ value: 'millions', label: 'Millions (M)' },
{ value: 'billions', label: 'Billions (B)' }
];
function buildStatementMetrics(
surface: Extract<GraphableFinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>,
metrics: Array<{
key: string;
label: string;
category: string;
order: number;
unit: FinancialUnit;
}>
) {
return metrics.map((metric) => ({
...metric,
surface,
supportedCadences: ['annual', 'quarterly', 'ltm'] as const
})) satisfies GraphMetricDefinition[];
}
export const GRAPH_METRIC_CATALOG: Record<GraphableFinancialSurfaceKind, GraphMetricDefinition[]> = {
income_statement: buildStatementMetrics('income_statement', INCOME_STATEMENT_METRIC_DEFINITIONS),
balance_sheet: buildStatementMetrics('balance_sheet', BALANCE_SHEET_METRIC_DEFINITIONS),
cash_flow_statement: buildStatementMetrics('cash_flow_statement', CASH_FLOW_STATEMENT_METRIC_DEFINITIONS),
ratios: RATIO_DEFINITIONS.map((metric) => ({
surface: 'ratios',
key: metric.key,
label: metric.label,
category: metric.category,
order: metric.order,
unit: metric.unit,
supportedCadences: metric.supportedCadences ?? ['annual', 'quarterly', 'ltm']
}))
};
export const DEFAULT_GRAPH_METRIC_BY_SURFACE: Record<GraphableFinancialSurfaceKind, string> = {
income_statement: 'revenue',
balance_sheet: 'total_assets',
cash_flow_statement: 'free_cash_flow',
ratios: 'gross_margin'
};
export function normalizeGraphTickers(value: string | null | undefined) {
const raw = (value ?? '')
.split(',')
.map((entry) => entry.trim().toUpperCase())
.filter(Boolean);
const unique = new Set<string>();
for (const ticker of raw) {
unique.add(ticker);
if (unique.size >= 5) {
break;
}
}
return [...unique];
}
export function isGraphSurfaceKind(value: string | null | undefined): value is GraphableFinancialSurfaceKind {
return GRAPHABLE_FINANCIAL_SURFACES.includes(value as GraphableFinancialSurfaceKind);
}
export function isGraphCadence(value: string | null | undefined): value is FinancialCadence {
return value === 'annual' || value === 'quarterly' || value === 'ltm';
}
export function isGraphChartKind(value: string | null | undefined): value is GraphChartKind {
return value === 'line' || value === 'bar';
}
export function isNumberScaleUnit(value: string | null | undefined): value is NumberScaleUnit {
return value === 'thousands' || value === 'millions' || value === 'billions';
}
export function metricsForSurfaceAndCadence(
surface: GraphableFinancialSurfaceKind,
cadence: FinancialCadence
) {
return GRAPH_METRIC_CATALOG[surface].filter((metric) => metric.supportedCadences.includes(cadence));
}
export function resolveGraphMetric(
surface: GraphableFinancialSurfaceKind,
cadence: FinancialCadence,
metric: string | null | undefined
) {
const metrics = metricsForSurfaceAndCadence(surface, cadence);
const normalizedMetric = metric?.trim() ?? '';
const match = metrics.find((candidate) => candidate.key === normalizedMetric);
if (match) {
return match.key;
}
const surfaceDefault = metrics.find((candidate) => candidate.key === DEFAULT_GRAPH_METRIC_BY_SURFACE[surface]);
return surfaceDefault?.key ?? metrics[0]?.key ?? DEFAULT_GRAPH_METRIC_BY_SURFACE[surface];
}
export function getGraphMetricDefinition(
surface: GraphableFinancialSurfaceKind,
cadence: FinancialCadence,
metric: string
) {
return metricsForSurfaceAndCadence(surface, cadence).find((candidate) => candidate.key === metric) ?? null;
}
export function defaultGraphingState(): GraphingUrlState {
return {
tickers: [...DEFAULT_GRAPH_TICKERS],
surface: DEFAULT_GRAPH_SURFACE,
metric: DEFAULT_GRAPH_METRIC_BY_SURFACE[DEFAULT_GRAPH_SURFACE],
cadence: DEFAULT_GRAPH_CADENCE,
chart: DEFAULT_GRAPH_CHART,
scale: DEFAULT_GRAPH_SCALE
};
}
export function parseGraphingParams(searchParams: SearchParamsLike): GraphingUrlState {
const tickers = normalizeGraphTickers(searchParams.get('tickers'));
const surface = isGraphSurfaceKind(searchParams.get('surface'))
? searchParams.get('surface') as GraphableFinancialSurfaceKind
: DEFAULT_GRAPH_SURFACE;
const cadence = isGraphCadence(searchParams.get('cadence'))
? searchParams.get('cadence') as FinancialCadence
: DEFAULT_GRAPH_CADENCE;
const metric = resolveGraphMetric(surface, cadence, searchParams.get('metric'));
const chart = isGraphChartKind(searchParams.get('chart'))
? searchParams.get('chart') as GraphChartKind
: DEFAULT_GRAPH_CHART;
const scale = isNumberScaleUnit(searchParams.get('scale'))
? searchParams.get('scale') as NumberScaleUnit
: DEFAULT_GRAPH_SCALE;
return {
tickers: tickers.length > 0 ? tickers : [...DEFAULT_GRAPH_TICKERS],
surface,
metric,
cadence,
chart,
scale
};
}
export function serializeGraphingParams(state: GraphingUrlState) {
const params = new URLSearchParams();
params.set('tickers', state.tickers.join(','));
params.set('surface', state.surface);
params.set('metric', state.metric);
params.set('cadence', state.cadence);
params.set('chart', state.chart);
params.set('scale', state.scale);
return params.toString();
}
export function withPrimaryGraphTicker(ticker: string | null | undefined) {
const normalized = ticker?.trim().toUpperCase() ?? '';
if (!normalized) {
return [...DEFAULT_GRAPH_TICKERS];
}
return normalizeGraphTickers([normalized, ...DEFAULT_GRAPH_TICKERS].join(','));
}
export function buildGraphingHref(primaryTicker?: string | null) {
const tickers = withPrimaryGraphTicker(primaryTicker);
const params = serializeGraphingParams({
...defaultGraphingState(),
tickers
});
return `/graphing?${params}`;
}

225
lib/graphing/series.test.ts Normal file
View File

@@ -0,0 +1,225 @@
import { describe, expect, it } from 'bun:test';
import { buildGraphingComparisonData } from '@/lib/graphing/series';
import type {
CompanyFinancialStatementsResponse,
FinancialStatementPeriod,
RatioRow,
StandardizedFinancialRow
} from '@/lib/types';
function createPeriod(input: {
id: string;
filingId: number;
filingDate: string;
periodEnd: string;
filingType?: '10-K' | '10-Q';
}) {
return {
id: input.id,
filingId: input.filingId,
accessionNumber: `0000-${input.filingId}`,
filingDate: input.filingDate,
periodStart: '2025-01-01',
periodEnd: input.periodEnd,
filingType: input.filingType ?? '10-Q',
periodLabel: input.id
} satisfies FinancialStatementPeriod;
}
function createStatementRow(key: string, values: Record<string, number | null>, unit: StandardizedFinancialRow['unit'] = 'currency') {
return {
key,
label: key,
category: 'test',
order: 10,
unit,
values,
sourceConcepts: [key],
sourceRowKeys: [key],
sourceFactIds: [1],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key]))
} satisfies StandardizedFinancialRow;
}
function createRatioRow(key: string, values: Record<string, number | null>, unit: RatioRow['unit'] = 'percent') {
return {
...createStatementRow(key, values, unit),
denominatorKey: null
} satisfies RatioRow;
}
function createFinancials(input: {
ticker: string;
companyName: string;
periods: FinancialStatementPeriod[];
statementRows?: StandardizedFinancialRow[];
ratioRows?: RatioRow[];
}) {
return {
company: {
ticker: input.ticker,
companyName: input.companyName,
cik: null
},
surfaceKind: 'income_statement',
cadence: 'annual',
displayModes: ['standardized'],
defaultDisplayMode: 'standardized',
periods: input.periods,
statementRows: {
faithful: [],
standardized: input.statementRows ?? []
},
ratioRows: input.ratioRows ?? [],
kpiRows: null,
trendSeries: [],
categories: [],
availability: {
adjusted: false,
customMetrics: false
},
nextCursor: null,
facts: null,
coverage: {
filings: input.periods.length,
rows: input.statementRows?.length ?? 0,
dimensions: 0,
facts: 0
},
dataSourceStatus: {
enabled: true,
hydratedFilings: input.periods.length,
partialFilings: 0,
failedFilings: 0,
pendingFilings: 0,
queuedSync: false
},
metrics: {
taxonomy: null,
validation: null
},
dimensionBreakdown: null
} satisfies CompanyFinancialStatementsResponse;
}
describe('graphing series', () => {
it('aligns multiple companies onto a union date axis', () => {
const data = buildGraphingComparisonData({
surface: 'income_statement',
metric: 'revenue',
results: [
{
ticker: 'MSFT',
financials: createFinancials({
ticker: 'MSFT',
companyName: 'Microsoft',
periods: [
createPeriod({ id: 'msft-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' }),
createPeriod({ id: 'msft-q2', filingId: 2, filingDate: '2025-04-28', periodEnd: '2025-03-31' })
],
statementRows: [createStatementRow('revenue', { 'msft-q1': 100, 'msft-q2': 120 })]
})
},
{
ticker: 'AAPL',
financials: createFinancials({
ticker: 'AAPL',
companyName: 'Apple',
periods: [
createPeriod({ id: 'aapl-q1', filingId: 3, filingDate: '2025-02-02', periodEnd: '2025-01-31' }),
createPeriod({ id: 'aapl-q2', filingId: 4, filingDate: '2025-04-30', periodEnd: '2025-03-31' })
],
statementRows: [createStatementRow('revenue', { 'aapl-q1': 90, 'aapl-q2': 130 })]
})
}
]
});
expect(data.chartData.map((entry) => entry.dateKey)).toEqual([
'2024-12-31',
'2025-01-31',
'2025-03-31'
]);
expect(data.chartData[0]?.MSFT).toBe(100);
expect(data.chartData[0]?.AAPL).toBeNull();
expect(data.chartData[1]?.AAPL).toBe(90);
});
it('preserves partial failures without blanking the whole chart', () => {
const data = buildGraphingComparisonData({
surface: 'income_statement',
metric: 'revenue',
results: [
{
ticker: 'MSFT',
financials: createFinancials({
ticker: 'MSFT',
companyName: 'Microsoft',
periods: [createPeriod({ id: 'msft-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' })],
statementRows: [createStatementRow('revenue', { 'msft-q1': 100 })]
})
},
{
ticker: 'FAIL',
error: 'Ticker not found'
}
]
});
expect(data.hasAnyData).toBe(true);
expect(data.hasPartialData).toBe(true);
expect(data.latestRows.find((row) => row.ticker === 'FAIL')?.status).toBe('error');
});
it('marks companies with missing metric values as no metric data', () => {
const data = buildGraphingComparisonData({
surface: 'ratios',
metric: 'gross_margin',
results: [
{
ticker: 'NVDA',
financials: createFinancials({
ticker: 'NVDA',
companyName: 'NVIDIA',
periods: [createPeriod({ id: 'nvda-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' })],
ratioRows: [createRatioRow('gross_margin', { 'nvda-q1': null })]
})
}
]
});
expect(data.latestRows[0]?.status).toBe('no_metric_data');
expect(data.hasAnyData).toBe(false);
});
it('derives latest and prior values for the summary table', () => {
const data = buildGraphingComparisonData({
surface: 'income_statement',
metric: 'revenue',
results: [
{
ticker: 'AMD',
financials: createFinancials({
ticker: 'AMD',
companyName: 'AMD',
periods: [
createPeriod({ id: 'amd-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' }),
createPeriod({ id: 'amd-q2', filingId: 2, filingDate: '2025-04-30', periodEnd: '2025-03-31' })
],
statementRows: [createStatementRow('revenue', { 'amd-q1': 50, 'amd-q2': 70 })]
})
}
]
});
expect(data.latestRows[0]).toMatchObject({
ticker: 'AMD',
latestValue: 70,
priorValue: 50,
changeValue: 20,
latestDateKey: '2025-03-31'
});
});
});

189
lib/graphing/series.ts Normal file
View File

@@ -0,0 +1,189 @@
import type {
CompanyFinancialStatementsResponse,
FinancialUnit,
RatioRow,
StandardizedFinancialRow
} from '@/lib/types';
import type { GraphableFinancialSurfaceKind } from '@/lib/financial-metrics';
type GraphingMetricRow = StandardizedFinancialRow | RatioRow;
export type GraphingFetchResult = {
ticker: string;
financials?: CompanyFinancialStatementsResponse;
error?: string;
};
export type GraphingSeriesPoint = {
periodId: string;
dateKey: string;
filingType: '10-K' | '10-Q';
filingDate: string;
periodEnd: string | null;
periodLabel: string;
value: number | null;
};
export type GraphingCompanySeries = {
ticker: string;
companyName: string;
status: 'ready' | 'error' | 'no_metric_data';
errorMessage: string | null;
unit: FinancialUnit | null;
points: GraphingSeriesPoint[];
latestPoint: GraphingSeriesPoint | null;
priorPoint: GraphingSeriesPoint | null;
};
export type GraphingLatestValueRow = {
ticker: string;
companyName: string;
status: GraphingCompanySeries['status'];
errorMessage: string | null;
latestValue: number | null;
priorValue: number | null;
changeValue: number | null;
latestDateKey: string | null;
latestPeriodLabel: string | null;
latestFilingType: '10-K' | '10-Q' | null;
};
export type GraphingChartDatum = Record<string, unknown> & {
dateKey: string;
dateMs: number;
};
export type GraphingComparisonData = {
companies: GraphingCompanySeries[];
chartData: GraphingChartDatum[];
latestRows: GraphingLatestValueRow[];
hasAnyData: boolean;
hasPartialData: boolean;
};
function sortPeriods(left: { periodEnd: string | null; filingDate: string }, right: { periodEnd: string | null; filingDate: string }) {
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
}
function extractMetricRow(
financials: CompanyFinancialStatementsResponse,
surface: GraphableFinancialSurfaceKind,
metric: string
): GraphingMetricRow | null {
if (surface === 'ratios') {
return financials.ratioRows?.find((row) => row.key === metric) ?? null;
}
return financials.statementRows?.standardized.find((row) => row.key === metric) ?? null;
}
function extractCompanySeries(
result: GraphingFetchResult,
surface: GraphableFinancialSurfaceKind,
metric: string
): GraphingCompanySeries {
if (result.error || !result.financials) {
return {
ticker: result.ticker,
companyName: result.ticker,
status: 'error',
errorMessage: result.error ?? 'Unable to load financial history',
unit: null,
points: [],
latestPoint: null,
priorPoint: null
};
}
const metricRow = extractMetricRow(result.financials, surface, metric);
const periods = [...result.financials.periods].sort(sortPeriods);
const points = periods.map((period) => ({
periodId: period.id,
dateKey: period.periodEnd ?? period.filingDate,
filingType: period.filingType,
filingDate: period.filingDate,
periodEnd: period.periodEnd,
periodLabel: period.periodLabel,
value: metricRow?.values[period.id] ?? null
}));
const populatedPoints = points.filter((point) => point.value !== null);
const latestPoint = populatedPoints[populatedPoints.length - 1] ?? null;
const priorPoint = populatedPoints.length > 1 ? populatedPoints[populatedPoints.length - 2] ?? null : null;
return {
ticker: result.financials.company.ticker,
companyName: result.financials.company.companyName,
status: latestPoint ? 'ready' : 'no_metric_data',
errorMessage: latestPoint ? null : 'No data available for the selected metric.',
unit: metricRow?.unit ?? null,
points,
latestPoint,
priorPoint
};
}
export function buildGraphingComparisonData(input: {
results: GraphingFetchResult[];
surface: GraphableFinancialSurfaceKind;
metric: string;
}): GraphingComparisonData {
const companies = input.results.map((result) => extractCompanySeries(result, input.surface, input.metric));
const chartDatumByDate = new Map<string, GraphingChartDatum>();
for (const company of companies) {
for (const point of company.points) {
const dateMs = Date.parse(point.dateKey);
const existing = chartDatumByDate.get(point.dateKey) ?? {
dateKey: point.dateKey,
dateMs
};
existing[company.ticker] = point.value;
existing[`meta__${company.ticker}`] = point;
chartDatumByDate.set(point.dateKey, existing);
}
}
const chartData = [...chartDatumByDate.values()]
.sort((left, right) => left.dateMs - right.dateMs)
.map((datum) => {
for (const company of companies) {
if (!(company.ticker in datum)) {
datum[company.ticker] = null;
}
}
return datum;
});
const latestRows = companies.map((company) => ({
ticker: company.ticker,
companyName: company.companyName,
status: company.status,
errorMessage: company.errorMessage,
latestValue: company.latestPoint?.value ?? null,
priorValue: company.priorPoint?.value ?? null,
changeValue:
company.latestPoint?.value !== null
&& company.latestPoint?.value !== undefined
&& company.priorPoint?.value !== null
&& company.priorPoint?.value !== undefined
? company.latestPoint.value - company.priorPoint.value
: null,
latestDateKey: company.latestPoint?.dateKey ?? null,
latestPeriodLabel: company.latestPoint?.periodLabel ?? null,
latestFilingType: company.latestPoint?.filingType ?? null
}));
const hasAnyData = companies.some((company) => company.latestPoint !== null);
const hasPartialData = companies.some((company) => company.status !== 'ready')
|| companies.some((company) => company.points.some((point) => point.value === null));
return {
companies,
chartData,
latestRows,
hasAnyData,
hasPartialData
};
}

View File

@@ -13,6 +13,18 @@ export const queryKeys = {
) => ['financials-v3', ticker, surfaceKind, cadence, includeDimensions ? 'dims' : 'no-dims', includeFacts ? 'facts' : 'rows', factsCursor ?? '', factsLimit, cursor ?? '', limit] as const, ) => ['financials-v3', ticker, surfaceKind, cadence, includeDimensions ? 'dims' : 'no-dims', includeFacts ? 'facts' : 'rows', factsCursor ?? '', factsLimit, cursor ?? '', limit] as const,
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,
researchWorkspace: (ticker: string) => ['research', 'workspace', ticker] as const,
researchLibrary: (
ticker: string,
q: string,
kind: string,
tag: string,
source: string,
linkedToMemo: string,
limit: number
) => ['research', 'library', ticker, q, kind, tag, source, linkedToMemo, limit] as const,
researchMemo: (ticker: string) => ['research', 'memo', ticker] as const,
researchPacket: (ticker: string) => ['research', 'packet', ticker] as const,
watchlist: () => ['watchlist'] as const, watchlist: () => ['watchlist'] as const,
researchJournal: (ticker: string) => ['research', 'journal', ticker] as const, researchJournal: (ticker: string) => ['research', 'journal', ticker] as const,
holdings: () => ['portfolio', 'holdings'] as const, holdings: () => ['portfolio', 'holdings'] as const,

View File

@@ -5,9 +5,13 @@ import {
getCompanyFinancialStatements, getCompanyFinancialStatements,
getLatestPortfolioInsight, getLatestPortfolioInsight,
getPortfolioSummary, getPortfolioSummary,
getResearchMemo,
getResearchPacket,
getResearchWorkspace,
getTask, getTask,
getTaskTimeline, getTaskTimeline,
listFilings, listFilings,
listResearchLibrary,
listHoldings, listHoldings,
listRecentTasks, listRecentTasks,
listResearchJournal, listResearchJournal,
@@ -16,7 +20,9 @@ import {
import { queryKeys } from '@/lib/query/keys'; import { queryKeys } from '@/lib/query/keys';
import type { import type {
FinancialCadence, FinancialCadence,
FinancialSurfaceKind FinancialSurfaceKind,
ResearchArtifactKind,
ResearchArtifactSource
} from '@/lib/types'; } from '@/lib/types';
export function companyAnalysisQueryOptions(ticker: string) { export function companyAnalysisQueryOptions(ticker: string) {
@@ -96,6 +102,68 @@ export function aiReportQueryOptions(accessionNumber: string) {
}); });
} }
export function researchWorkspaceQueryOptions(ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase();
return queryOptions({
queryKey: queryKeys.researchWorkspace(normalizedTicker),
queryFn: () => getResearchWorkspace(normalizedTicker),
staleTime: 15_000
});
}
export function researchLibraryQueryOptions(input: {
ticker: string;
q?: string;
kind?: ResearchArtifactKind;
tag?: string;
source?: ResearchArtifactSource;
linkedToMemo?: boolean;
limit?: number;
}) {
const normalizedTicker = input.ticker.trim().toUpperCase();
const q = input.q?.trim() ?? '';
const kind = input.kind ?? '';
const tag = input.tag?.trim() ?? '';
const source = input.source ?? '';
const linkedToMemo = input.linkedToMemo === undefined ? '' : input.linkedToMemo ? 'true' : 'false';
const limit = input.limit ?? 100;
return queryOptions({
queryKey: queryKeys.researchLibrary(normalizedTicker, q, kind, tag, source, linkedToMemo, limit),
queryFn: () => listResearchLibrary({
ticker: normalizedTicker,
q,
kind: input.kind,
tag,
source: input.source,
linkedToMemo: input.linkedToMemo,
limit
}),
staleTime: 10_000
});
}
export function researchMemoQueryOptions(ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase();
return queryOptions({
queryKey: queryKeys.researchMemo(normalizedTicker),
queryFn: () => getResearchMemo(normalizedTicker),
staleTime: 10_000
});
}
export function researchPacketQueryOptions(ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase();
return queryOptions({
queryKey: queryKeys.researchPacket(normalizedTicker),
queryFn: () => getResearchPacket(normalizedTicker),
staleTime: 10_000
});
}
export function watchlistQueryOptions() { export function watchlistQueryOptions() {
return queryOptions({ return queryOptions({
queryKey: queryKeys.watchlist(), queryKey: queryKeys.watchlist(),

View File

@@ -7,7 +7,12 @@ import type {
FinancialCadence, FinancialCadence,
FinancialStatementKind, FinancialStatementKind,
FinancialSurfaceKind, FinancialSurfaceKind,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchJournalEntryType, ResearchJournalEntryType,
ResearchMemoConviction,
ResearchMemoRating,
ResearchMemoSection,
TaskStatus TaskStatus
} from '@/lib/types'; } from '@/lib/types';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
@@ -32,6 +37,22 @@ import {
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 {
addResearchMemoEvidenceLink,
createAiReportArtifactFromAccession,
createFilingArtifactFromAccession,
createResearchArtifactRecord,
deleteResearchArtifactRecord,
deleteResearchMemoEvidenceLink,
getResearchArtifactFileResponse,
getResearchMemoByTicker,
getResearchPacket,
getResearchWorkspace,
listResearchArtifacts,
storeResearchUpload,
updateResearchArtifactRecord,
upsertResearchMemoRecord
} from '@/lib/server/repos/research-library';
import { import {
createResearchJournalEntryRecord, createResearchJournalEntryRecord,
deleteResearchJournalEntryRecord, deleteResearchJournalEntryRecord,
@@ -82,6 +103,18 @@ const FINANCIAL_SURFACES: FinancialSurfaceKind[] = [
const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive']; const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive'];
const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high']; const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high'];
const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change']; const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change'];
const RESEARCH_ARTIFACT_KINDS: ResearchArtifactKind[] = ['filing', 'ai_report', 'note', 'upload', 'memo_snapshot', 'status_change'];
const RESEARCH_ARTIFACT_SOURCES: ResearchArtifactSource[] = ['system', 'user'];
const RESEARCH_MEMO_RATINGS: ResearchMemoRating[] = ['strong_buy', 'buy', 'hold', 'sell'];
const RESEARCH_MEMO_CONVICTIONS: ResearchMemoConviction[] = ['low', 'medium', 'high'];
const RESEARCH_MEMO_SECTIONS: ResearchMemoSection[] = [
'thesis',
'variant_view',
'catalysts',
'risks',
'disconfirming_evidence',
'next_actions'
];
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)) {
@@ -205,6 +238,44 @@ function asJournalEntryType(value: unknown) {
: undefined; : undefined;
} }
function asResearchArtifactKind(value: unknown) {
return RESEARCH_ARTIFACT_KINDS.includes(value as ResearchArtifactKind)
? value as ResearchArtifactKind
: undefined;
}
function asResearchArtifactSource(value: unknown) {
return RESEARCH_ARTIFACT_SOURCES.includes(value as ResearchArtifactSource)
? value as ResearchArtifactSource
: undefined;
}
function asResearchMemoRating(value: unknown) {
if (value === null) {
return null;
}
return RESEARCH_MEMO_RATINGS.includes(value as ResearchMemoRating)
? value as ResearchMemoRating
: undefined;
}
function asResearchMemoConviction(value: unknown) {
if (value === null) {
return null;
}
return RESEARCH_MEMO_CONVICTIONS.includes(value as ResearchMemoConviction)
? value as ResearchMemoConviction
: undefined;
}
function asResearchMemoSection(value: unknown) {
return RESEARCH_MEMO_SECTIONS.includes(value as ResearchMemoSection)
? value as ResearchMemoSection
: undefined;
}
function formatLabel(value: string) { function formatLabel(value: string) {
return value return value
.split('_') .split('_')
@@ -212,6 +283,10 @@ function formatLabel(value: string) {
.join(' '); .join(' ');
} }
function normalizeTicker(value: unknown) {
return typeof value === 'string' ? value.trim().toUpperCase() : '';
}
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;
@@ -707,6 +782,383 @@ export const app = new Elysia({ prefix: '/api' })
return Response.json({ insight }); return Response.json({ insight });
}) })
.get('/research/workspace', 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 workspace = await getResearchWorkspace(session.user.id, ticker);
return Response.json({ workspace });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.get('/research/library', 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 linkedToMemo = query.linkedToMemo === undefined
? null
: asBoolean(query.linkedToMemo, false);
const library = await listResearchArtifacts(session.user.id, {
ticker,
q: asOptionalString(query.q),
kind: asResearchArtifactKind(query.kind) ?? null,
tag: asOptionalString(query.tag),
source: asResearchArtifactSource(query.source) ?? null,
linkedToMemo,
limit: typeof query.limit === 'number' ? query.limit : Number(query.limit ?? 100)
});
return Response.json(library);
}, {
query: t.Object({
ticker: t.String({ minLength: 1 }),
q: t.Optional(t.String()),
kind: t.Optional(t.String()),
tag: t.Optional(t.String()),
source: t.Optional(t.String()),
linkedToMemo: t.Optional(t.String()),
limit: t.Optional(t.Numeric())
})
})
.post('/research/library', 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 kind = asResearchArtifactKind(payload.kind);
const source = asResearchArtifactSource(payload.source);
const title = asOptionalString(payload.title);
const summary = asOptionalString(payload.summary);
const bodyMarkdown = asOptionalString(payload.bodyMarkdown);
if (!ticker) {
return jsonError('ticker is required');
}
if (!kind) {
return jsonError('kind is required');
}
if (kind === 'upload') {
return jsonError('Use /api/research/library/upload for file uploads');
}
if (!title && !summary && !bodyMarkdown) {
return jsonError('title, summary, or bodyMarkdown is required');
}
try {
const artifact = await createResearchArtifactRecord({
userId: session.user.id,
ticker,
accessionNumber: asOptionalString(payload.accessionNumber),
kind,
source: source ?? 'user',
subtype: asOptionalString(payload.subtype),
title,
summary,
bodyMarkdown,
tags: asTags(payload.tags),
metadata: asOptionalRecord(payload.metadata)
});
await updateWatchlistReviewByTicker(session.user.id, ticker, artifact.updated_at);
return Response.json({ artifact });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to create research artifact'));
}
})
.post('/research/library/upload', async ({ request }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
try {
const form = await request.formData();
const ticker = normalizeTicker(String(form.get('ticker') ?? ''));
const title = asOptionalString(String(form.get('title') ?? ''));
const summary = asOptionalString(String(form.get('summary') ?? ''));
const tags = asTags(String(form.get('tags') ?? ''));
const file = form.get('file');
if (!ticker) {
return jsonError('ticker is required');
}
if (!(file instanceof File)) {
return jsonError('file is required');
}
const artifact = await storeResearchUpload({
userId: session.user.id,
ticker,
file,
title,
summary,
tags
});
return Response.json({ artifact });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to upload research file'));
}
})
.patch('/research/library/: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 artifact id', 400);
}
const payload = asRecord(body);
try {
const artifact = await updateResearchArtifactRecord({
userId: session.user.id,
id: numericId,
title: payload.title === undefined ? undefined : asOptionalString(payload.title),
summary: payload.summary === undefined ? undefined : asOptionalString(payload.summary),
bodyMarkdown: payload.bodyMarkdown === undefined
? undefined
: (typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown : ''),
tags: payload.tags === undefined ? undefined : asTags(payload.tags),
metadata: payload.metadata === undefined ? undefined : asOptionalRecord(payload.metadata)
});
if (!artifact) {
return jsonError('Research artifact not found', 404);
}
return Response.json({ artifact });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to update research artifact'));
}
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.delete('/research/library/: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 artifact id', 400);
}
const removed = await deleteResearchArtifactRecord(session.user.id, numericId);
if (!removed) {
return jsonError('Research artifact not found', 404);
}
return Response.json({ success: true });
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.get('/research/library/:id/file', 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 artifact id', 400);
}
const fileResponse = await getResearchArtifactFileResponse(session.user.id, numericId);
if (!fileResponse) {
return jsonError('Research upload not found', 404);
}
return fileResponse;
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.get('/research/memo', 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 memo = await getResearchMemoByTicker(session.user.id, ticker);
return Response.json({ memo });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.put('/research/memo', 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() : '';
if (!ticker) {
return jsonError('ticker is required');
}
const rating = asResearchMemoRating(payload.rating);
const conviction = asResearchMemoConviction(payload.conviction);
if (payload.rating !== undefined && rating === undefined) {
return jsonError('Invalid memo rating', 400);
}
if (payload.conviction !== undefined && conviction === undefined) {
return jsonError('Invalid memo conviction', 400);
}
try {
const memo = await upsertResearchMemoRecord({
userId: session.user.id,
ticker,
rating,
conviction,
timeHorizonMonths: payload.timeHorizonMonths === undefined
? undefined
: (typeof payload.timeHorizonMonths === 'number' ? payload.timeHorizonMonths : Number(payload.timeHorizonMonths)),
packetTitle: payload.packetTitle === undefined ? undefined : asOptionalString(payload.packetTitle),
packetSubtitle: payload.packetSubtitle === undefined ? undefined : asOptionalString(payload.packetSubtitle),
thesisMarkdown: payload.thesisMarkdown === undefined ? undefined : String(payload.thesisMarkdown),
variantViewMarkdown: payload.variantViewMarkdown === undefined ? undefined : String(payload.variantViewMarkdown),
catalystsMarkdown: payload.catalystsMarkdown === undefined ? undefined : String(payload.catalystsMarkdown),
risksMarkdown: payload.risksMarkdown === undefined ? undefined : String(payload.risksMarkdown),
disconfirmingEvidenceMarkdown: payload.disconfirmingEvidenceMarkdown === undefined ? undefined : String(payload.disconfirmingEvidenceMarkdown),
nextActionsMarkdown: payload.nextActionsMarkdown === undefined ? undefined : String(payload.nextActionsMarkdown)
});
return Response.json({ memo });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to save research memo'));
}
})
.post('/research/memo/:id/evidence', 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 memo id', 400);
}
const payload = asRecord(body);
const section = asResearchMemoSection(payload.section);
const artifactId = typeof payload.artifactId === 'number' ? payload.artifactId : Number(payload.artifactId);
if (!section) {
return jsonError('section is required', 400);
}
if (!Number.isInteger(artifactId) || artifactId <= 0) {
return jsonError('artifactId is required', 400);
}
try {
const evidence = await addResearchMemoEvidenceLink({
userId: session.user.id,
memoId: numericId,
artifactId,
section,
annotation: asOptionalString(payload.annotation),
sortOrder: payload.sortOrder === undefined
? undefined
: (typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder))
});
return Response.json({ evidence });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to attach memo evidence'));
}
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.delete('/research/memo/:id/evidence/:linkId', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const memoId = Number(params.id);
const linkId = Number(params.linkId);
if (!Number.isInteger(memoId) || memoId <= 0 || !Number.isInteger(linkId) || linkId <= 0) {
return jsonError('Invalid memo evidence id', 400);
}
const removed = await deleteResearchMemoEvidenceLink(session.user.id, memoId, linkId);
if (!removed) {
return jsonError('Memo evidence not found', 404);
}
return Response.json({ success: true });
}, {
params: t.Object({
id: t.String({ minLength: 1 }),
linkId: t.String({ minLength: 1 })
})
})
.get('/research/packet', 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 packet = await getResearchPacket(session.user.id, ticker);
return Response.json({ packet });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.get('/research/journal', async ({ query }) => { .get('/research/journal', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession(); const { session, response } = await requireAuthenticatedSession();
if (response) { if (response) {
@@ -762,6 +1214,10 @@ export const app = new Elysia({ prefix: '/api' })
metadata metadata
}); });
if (!entry) {
return jsonError('Failed to create journal entry', 500);
}
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at); await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
return Response.json({ entry }); return Response.json({ entry });

View File

@@ -24,6 +24,8 @@ describe('sqlite schema compatibility bootstrap', () => {
expect(__dbInternals.hasColumn(client, 'holding', 'company_name')).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); expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(false);
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(false);
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(false);
__dbInternals.ensureLocalSqliteSchema(client); __dbInternals.ensureLocalSqliteSchema(client);
@@ -37,6 +39,9 @@ describe('sqlite schema compatibility bootstrap', () => {
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); expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_memo_evidence')).toBe(true);
client.close(); client.close();
}); });

View File

@@ -50,7 +50,304 @@ function applySqlFile(client: Database, fileName: string) {
client.exec(sql); client.exec(sql);
} }
function applyBaseSchemaCompat(client: Database) {
const sql = readFileSync(join(process.cwd(), 'drizzle', '0000_cold_silver_centurion.sql'), 'utf8')
.replaceAll('CREATE TABLE `', 'CREATE TABLE IF NOT EXISTS `')
.replaceAll('CREATE UNIQUE INDEX `', 'CREATE UNIQUE INDEX IF NOT EXISTS `')
.replaceAll('CREATE INDEX `', 'CREATE INDEX IF NOT EXISTS `');
client.exec(sql);
}
function ensureResearchWorkspaceSchema(client: Database) {
if (!hasTable(client, 'research_artifact')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_artifact\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`user_id\` text NOT NULL,
\`organization_id\` text,
\`ticker\` text NOT NULL,
\`accession_number\` text,
\`kind\` text NOT NULL,
\`source\` text NOT NULL DEFAULT 'user',
\`subtype\` text,
\`title\` text,
\`summary\` text,
\`body_markdown\` text,
\`search_text\` text,
\`visibility_scope\` text NOT NULL DEFAULT 'private',
\`tags\` text,
\`metadata\` text,
\`file_name\` text,
\`mime_type\` text,
\`file_size_bytes\` integer,
\`storage_path\` 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,
FOREIGN KEY (\`organization_id\`) REFERENCES \`organization\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`);
}
if (!hasTable(client, 'research_memo')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_memo\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`user_id\` text NOT NULL,
\`organization_id\` text,
\`ticker\` text NOT NULL,
\`rating\` text,
\`conviction\` text,
\`time_horizon_months\` integer,
\`packet_title\` text,
\`packet_subtitle\` text,
\`thesis_markdown\` text NOT NULL DEFAULT '',
\`variant_view_markdown\` text NOT NULL DEFAULT '',
\`catalysts_markdown\` text NOT NULL DEFAULT '',
\`risks_markdown\` text NOT NULL DEFAULT '',
\`disconfirming_evidence_markdown\` text NOT NULL DEFAULT '',
\`next_actions_markdown\` text NOT NULL DEFAULT '',
\`created_at\` text NOT NULL,
\`updated_at\` text NOT NULL,
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`organization_id\`) REFERENCES \`organization\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`);
}
if (!hasTable(client, 'research_memo_evidence')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_memo_evidence\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`memo_id\` integer NOT NULL,
\`artifact_id\` integer NOT NULL,
\`section\` text NOT NULL,
\`annotation\` text,
\`sort_order\` integer NOT NULL DEFAULT 0,
\`created_at\` text NOT NULL,
FOREIGN KEY (\`memo_id\`) REFERENCES \`research_memo\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`artifact_id\`) REFERENCES \`research_artifact\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`);
}
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_ticker_idx` ON `research_artifact` (`user_id`, `ticker`, `updated_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_kind_idx` ON `research_artifact` (`user_id`, `kind`, `updated_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_accession_idx` ON `research_artifact` (`user_id`, `accession_number`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_source_idx` ON `research_artifact` (`user_id`, `source`, `updated_at`);');
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_memo_ticker_uidx` ON `research_memo` (`user_id`, `ticker`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_updated_idx` ON `research_memo` (`user_id`, `updated_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_evidence_memo_idx` ON `research_memo_evidence` (`memo_id`, `section`, `sort_order`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_evidence_artifact_idx` ON `research_memo_evidence` (`artifact_id`);');
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_memo_evidence_unique_uidx` ON `research_memo_evidence` (`memo_id`, `artifact_id`, `section`);');
client.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS \`research_artifact_fts\` USING fts5(
artifact_id UNINDEXED,
user_id UNINDEXED,
ticker UNINDEXED,
title,
summary,
body_markdown,
search_text,
tags_text
);
`);
client.exec(`
INSERT INTO \`research_artifact\` (
\`user_id\`,
\`organization_id\`,
\`ticker\`,
\`accession_number\`,
\`kind\`,
\`source\`,
\`subtype\`,
\`title\`,
\`summary\`,
\`body_markdown\`,
\`search_text\`,
\`visibility_scope\`,
\`tags\`,
\`metadata\`,
\`created_at\`,
\`updated_at\`
)
SELECT
r.\`user_id\`,
NULL,
r.\`ticker\`,
r.\`accession_number\`,
CASE
WHEN r.\`entry_type\` = 'status_change' THEN 'status_change'
ELSE 'note'
END,
CASE
WHEN r.\`entry_type\` = 'status_change' THEN 'system'
ELSE 'user'
END,
r.\`entry_type\`,
r.\`title\`,
CASE
WHEN r.\`body_markdown\` IS NULL OR TRIM(r.\`body_markdown\`) = '' THEN NULL
ELSE SUBSTR(r.\`body_markdown\`, 1, 280)
END,
r.\`body_markdown\`,
r.\`body_markdown\`,
'private',
NULL,
r.\`metadata\`,
r.\`created_at\`,
r.\`updated_at\`
FROM \`research_journal_entry\` r
WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'research_journal_entry')
AND NOT EXISTS (
SELECT 1
FROM \`research_artifact\` a
WHERE a.\`user_id\` = r.\`user_id\`
AND a.\`ticker\` = r.\`ticker\`
AND IFNULL(a.\`accession_number\`, '') = IFNULL(r.\`accession_number\`, '')
AND a.\`kind\` = CASE
WHEN r.\`entry_type\` = 'status_change' THEN 'status_change'
ELSE 'note'
END
AND IFNULL(a.\`title\`, '') = IFNULL(r.\`title\`, '')
AND a.\`created_at\` = r.\`created_at\`
);
`);
client.exec(`
INSERT INTO \`research_artifact\` (
\`user_id\`,
\`organization_id\`,
\`ticker\`,
\`accession_number\`,
\`kind\`,
\`source\`,
\`subtype\`,
\`title\`,
\`summary\`,
\`body_markdown\`,
\`search_text\`,
\`visibility_scope\`,
\`tags\`,
\`metadata\`,
\`created_at\`,
\`updated_at\`
)
SELECT
w.\`user_id\`,
NULL,
f.\`ticker\`,
f.\`accession_number\`,
'ai_report',
'system',
'filing_analysis',
f.\`filing_type\` || ' AI memo',
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
'Stored AI memo for ' || f.\`company_name\` || ' (' || f.\`ticker\` || ').' || CHAR(10) ||
'Accession: ' || f.\`accession_number\` || CHAR(10) || CHAR(10) ||
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
'private',
NULL,
json_object(
'provider', json_extract(f.\`analysis\`, '$.provider'),
'model', json_extract(f.\`analysis\`, '$.model'),
'filingType', f.\`filing_type\`,
'filingDate', f.\`filing_date\`
),
f.\`created_at\`,
f.\`updated_at\`
FROM \`filing\` f
JOIN \`watchlist_item\` w
ON w.\`ticker\` = f.\`ticker\`
WHERE f.\`analysis\` IS NOT NULL
AND TRIM(COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights'), '')) <> ''
AND NOT EXISTS (
SELECT 1
FROM \`research_artifact\` a
WHERE a.\`user_id\` = w.\`user_id\`
AND a.\`ticker\` = f.\`ticker\`
AND a.\`accession_number\` = f.\`accession_number\`
AND a.\`kind\` = 'ai_report'
);
`);
client.exec('DELETE FROM `research_artifact_fts`;');
client.exec(`
INSERT INTO \`research_artifact_fts\` (
\`artifact_id\`,
\`user_id\`,
\`ticker\`,
\`title\`,
\`summary\`,
\`body_markdown\`,
\`search_text\`,
\`tags_text\`
)
SELECT
\`id\`,
\`user_id\`,
\`ticker\`,
COALESCE(\`title\`, ''),
COALESCE(\`summary\`, ''),
COALESCE(\`body_markdown\`, ''),
COALESCE(\`search_text\`, ''),
CASE
WHEN \`tags\` IS NULL OR TRIM(\`tags\`) = '' THEN ''
ELSE REPLACE(REPLACE(REPLACE(\`tags\`, '[', ''), ']', ''), '\"', '')
END
FROM \`research_artifact\`;
`);
}
function ensureLocalSqliteSchema(client: Database) { function ensureLocalSqliteSchema(client: Database) {
const missingBaseSchema = [
'filing',
'watchlist_item',
'holding',
'task_run',
'portfolio_insight'
].some((tableName) => !hasTable(client, tableName));
if (missingBaseSchema) {
applyBaseSchemaCompat(client);
}
if (!hasTable(client, 'user')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`user\` (
\`id\` text PRIMARY KEY NOT NULL,
\`name\` text NOT NULL,
\`email\` text NOT NULL,
\`emailVerified\` integer NOT NULL DEFAULT 0,
\`image\` text,
\`createdAt\` integer NOT NULL,
\`updatedAt\` integer NOT NULL,
\`role\` text,
\`banned\` integer DEFAULT 0,
\`banReason\` text,
\`banExpires\` integer
);
`);
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `user_email_uidx` ON `user` (`email`);');
}
if (!hasTable(client, 'organization')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`organization\` (
\`id\` text PRIMARY KEY NOT NULL,
\`name\` text NOT NULL,
\`slug\` text NOT NULL,
\`logo\` text,
\`createdAt\` integer NOT NULL,
\`metadata\` text
);
`);
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `organization_slug_uidx` ON `organization` (`slug`);');
}
if (!hasTable(client, 'filing_statement_snapshot')) { if (!hasTable(client, 'filing_statement_snapshot')) {
applySqlFile(client, '0001_glossy_statement_snapshots.sql'); applySqlFile(client, '0001_glossy_statement_snapshots.sql');
} }
@@ -142,6 +439,8 @@ function ensureLocalSqliteSchema(client: Database) {
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_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`);'); client.exec('CREATE INDEX IF NOT EXISTS `research_journal_accession_idx` ON `research_journal_entry` (`user_id`, `accession_number`);');
} }
ensureResearchWorkspaceSchema(client);
} }
export function getSqliteClient() { export function getSqliteClient() {

View File

@@ -30,6 +30,18 @@ type TaxonomyMetricValidationStatus = 'not_run' | 'matched' | 'mismatch' | 'erro
type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive'; type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
type CoveragePriority = 'low' | 'medium' | 'high'; type CoveragePriority = 'low' | 'medium' | 'high';
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change'; type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
type ResearchArtifactKind = 'filing' | 'ai_report' | 'note' | 'upload' | 'memo_snapshot' | 'status_change';
type ResearchArtifactSource = 'system' | 'user';
type ResearchVisibilityScope = 'private' | 'organization';
type ResearchMemoRating = 'strong_buy' | 'buy' | 'hold' | 'sell';
type ResearchMemoConviction = 'low' | 'medium' | 'high';
type ResearchMemoSection =
| 'thesis'
| 'variant_view'
| 'catalysts'
| 'risks'
| 'disconfirming_evidence'
| 'next_actions';
type FinancialCadence = 'annual' | 'quarterly' | 'ltm'; type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
type FinancialSurfaceKind = type FinancialSurfaceKind =
| 'income_statement' | 'income_statement'
@@ -570,6 +582,72 @@ export const researchJournalEntry = sqliteTable('research_journal_entry', {
researchJournalAccessionIndex: index('research_journal_accession_idx').on(table.user_id, table.accession_number) researchJournalAccessionIndex: index('research_journal_accession_idx').on(table.user_id, table.accession_number)
})); }));
export const researchArtifact = sqliteTable('research_artifact', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
organization_id: text('organization_id').references(() => organization.id, { onDelete: 'set null' }),
ticker: text('ticker').notNull(),
accession_number: text('accession_number'),
kind: text('kind').$type<ResearchArtifactKind>().notNull(),
source: text('source').$type<ResearchArtifactSource>().notNull().default('user'),
subtype: text('subtype'),
title: text('title'),
summary: text('summary'),
body_markdown: text('body_markdown'),
search_text: text('search_text'),
visibility_scope: text('visibility_scope').$type<ResearchVisibilityScope>().notNull().default('private'),
tags: text('tags', { mode: 'json' }).$type<string[]>(),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown> | null>(),
file_name: text('file_name'),
mime_type: text('mime_type'),
file_size_bytes: integer('file_size_bytes'),
storage_path: text('storage_path'),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
}, (table) => ({
researchArtifactTickerIndex: index('research_artifact_ticker_idx').on(table.user_id, table.ticker, table.updated_at),
researchArtifactKindIndex: index('research_artifact_kind_idx').on(table.user_id, table.kind, table.updated_at),
researchArtifactAccessionIndex: index('research_artifact_accession_idx').on(table.user_id, table.accession_number),
researchArtifactSourceIndex: index('research_artifact_source_idx').on(table.user_id, table.source, table.updated_at)
}));
export const researchMemo = sqliteTable('research_memo', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
organization_id: text('organization_id').references(() => organization.id, { onDelete: 'set null' }),
ticker: text('ticker').notNull(),
rating: text('rating').$type<ResearchMemoRating>(),
conviction: text('conviction').$type<ResearchMemoConviction>(),
time_horizon_months: integer('time_horizon_months'),
packet_title: text('packet_title'),
packet_subtitle: text('packet_subtitle'),
thesis_markdown: text('thesis_markdown').notNull().default(''),
variant_view_markdown: text('variant_view_markdown').notNull().default(''),
catalysts_markdown: text('catalysts_markdown').notNull().default(''),
risks_markdown: text('risks_markdown').notNull().default(''),
disconfirming_evidence_markdown: text('disconfirming_evidence_markdown').notNull().default(''),
next_actions_markdown: text('next_actions_markdown').notNull().default(''),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
}, (table) => ({
researchMemoTickerUnique: uniqueIndex('research_memo_ticker_uidx').on(table.user_id, table.ticker),
researchMemoUpdatedIndex: index('research_memo_updated_idx').on(table.user_id, table.updated_at)
}));
export const researchMemoEvidence = sqliteTable('research_memo_evidence', {
id: integer('id').primaryKey({ autoIncrement: true }),
memo_id: integer('memo_id').notNull().references(() => researchMemo.id, { onDelete: 'cascade' }),
artifact_id: integer('artifact_id').notNull().references(() => researchArtifact.id, { onDelete: 'cascade' }),
section: text('section').$type<ResearchMemoSection>().notNull(),
annotation: text('annotation'),
sort_order: integer('sort_order').notNull().default(0),
created_at: text('created_at').notNull()
}, (table) => ({
researchMemoEvidenceMemoIndex: index('research_memo_evidence_memo_idx').on(table.memo_id, table.section, table.sort_order),
researchMemoEvidenceArtifactIndex: index('research_memo_evidence_artifact_idx').on(table.artifact_id),
researchMemoEvidenceUnique: uniqueIndex('research_memo_evidence_unique_uidx').on(table.memo_id, table.artifact_id, table.section)
}));
export const authSchema = { export const authSchema = {
user, user,
session, session,
@@ -595,7 +673,10 @@ export const appSchema = {
taskRun, taskRun,
taskStageEvent, taskStageEvent,
portfolioInsight, portfolioInsight,
researchJournalEntry researchJournalEntry,
researchArtifact,
researchMemo,
researchMemoEvidence
}; };
export const schema = { export const schema = {

View File

@@ -2,6 +2,11 @@ import type {
FinancialStatementKind, FinancialStatementKind,
FinancialUnit FinancialUnit
} from '@/lib/types'; } from '@/lib/types';
import {
BALANCE_SHEET_METRIC_DEFINITIONS,
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
INCOME_STATEMENT_METRIC_DEFINITIONS
} from '@/lib/financial-metrics';
export type CanonicalRowDefinition = { export type CanonicalRowDefinition = {
key: string; key: string;
@@ -13,73 +18,8 @@ export type CanonicalRowDefinition = {
labelIncludes?: readonly string[]; labelIncludes?: readonly string[];
}; };
const INCOME_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'revenue', label: 'Revenue', category: 'revenue', order: 10, unit: 'currency', localNames: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'TotalRevenuesAndOtherIncome'], labelIncludes: ['revenue', 'sales'] },
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
];
const BALANCE_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'cash_and_equivalents', label: 'Cash & Equivalents', category: 'asset', order: 10, unit: 'currency', localNames: ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsAndShortTermInvestments', 'CashAndShortTermInvestments'], labelIncludes: ['cash and cash equivalents'] },
{ key: 'short_term_investments', label: 'Short-Term Investments', category: 'asset', order: 20, unit: 'currency', localNames: ['AvailableForSaleSecuritiesCurrent', 'ShortTermInvestments'], labelIncludes: ['short-term investments', 'marketable securities'] },
{ key: 'accounts_receivable', label: 'Accounts Receivable', category: 'asset', order: 30, unit: 'currency', localNames: ['AccountsReceivableNetCurrent', 'ReceivablesNetCurrent'], labelIncludes: ['accounts receivable'] },
{ key: 'inventory', label: 'Inventory', category: 'asset', order: 40, unit: 'currency', localNames: ['InventoryNet'], labelIncludes: ['inventory'] },
{ key: 'other_current_assets', label: 'Other Current Assets', category: 'asset', order: 50, unit: 'currency', localNames: ['OtherAssetsCurrent'], labelIncludes: ['other current assets'] },
{ key: 'current_assets', label: 'Current Assets', category: 'asset', order: 60, unit: 'currency', localNames: ['AssetsCurrent'], labelIncludes: ['current assets'] },
{ key: 'property_plant_equipment', label: 'Property, Plant & Equipment', category: 'asset', order: 70, unit: 'currency', localNames: ['PropertyPlantAndEquipmentNet'], labelIncludes: ['property, plant and equipment', 'property and equipment'] },
{ key: 'goodwill', label: 'Goodwill', category: 'asset', order: 80, unit: 'currency', localNames: ['Goodwill'], labelIncludes: ['goodwill'] },
{ key: 'intangible_assets', label: 'Intangible Assets', category: 'asset', order: 90, unit: 'currency', localNames: ['FiniteLivedIntangibleAssetsNet', 'IndefiniteLivedIntangibleAssetsExcludingGoodwill', 'IntangibleAssetsNetExcludingGoodwill'], labelIncludes: ['intangible assets'] },
{ key: 'total_assets', label: 'Total Assets', category: 'asset', order: 100, unit: 'currency', localNames: ['Assets'], labelIncludes: ['total assets'] },
{ key: 'accounts_payable', label: 'Accounts Payable', category: 'liability', order: 110, unit: 'currency', localNames: ['AccountsPayableCurrent'], labelIncludes: ['accounts payable'] },
{ key: 'accrued_liabilities', label: 'Accrued Liabilities', category: 'liability', order: 120, unit: 'currency', localNames: ['AccruedLiabilitiesCurrent'], labelIncludes: ['accrued liabilities'] },
{ key: 'deferred_revenue_current', label: 'Deferred Revenue, Current', category: 'liability', order: 130, unit: 'currency', localNames: ['ContractWithCustomerLiabilityCurrent', 'DeferredRevenueCurrent'], labelIncludes: ['deferred revenue current', 'current deferred revenue'] },
{ key: 'current_liabilities', label: 'Current Liabilities', category: 'liability', order: 140, unit: 'currency', localNames: ['LiabilitiesCurrent'], labelIncludes: ['current liabilities'] },
{ key: 'long_term_debt', label: 'Long-Term Debt', category: 'liability', order: 150, unit: 'currency', localNames: ['LongTermDebtNoncurrent', 'LongTermDebt', 'DebtNoncurrent', 'LongTermDebtAndCapitalLeaseObligations'], labelIncludes: ['long-term debt'] },
{ key: 'current_debt', label: 'Current Debt', category: 'liability', order: 160, unit: 'currency', localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent'], labelIncludes: ['current debt', 'short-term debt'] },
{ key: 'lease_liabilities', label: 'Lease Liabilities', category: 'liability', order: 170, unit: 'currency', localNames: ['OperatingLeaseLiabilityNoncurrent', 'FinanceLeaseLiabilityNoncurrent', 'LesseeOperatingLeaseLiability'], labelIncludes: ['lease liabilities'] },
{ key: 'total_debt', label: 'Total Debt', category: 'liability', order: 180, unit: 'currency', localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'], labelIncludes: ['total debt'] },
{ key: 'deferred_revenue_noncurrent', label: 'Deferred Revenue, Noncurrent', category: 'liability', order: 190, unit: 'currency', localNames: ['ContractWithCustomerLiabilityNoncurrent', 'DeferredRevenueNoncurrent'], labelIncludes: ['deferred revenue noncurrent'] },
{ key: 'total_liabilities', label: 'Total Liabilities', category: 'liability', order: 200, unit: 'currency', localNames: ['Liabilities'], labelIncludes: ['total liabilities'] },
{ key: 'retained_earnings', label: 'Retained Earnings', category: 'equity', order: 210, unit: 'currency', localNames: ['RetainedEarningsAccumulatedDeficit'], labelIncludes: ['retained earnings', 'accumulated deficit'] },
{ key: 'total_equity', label: 'Total Equity', category: 'equity', order: 220, unit: 'currency', localNames: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'PartnersCapital'], labelIncludes: ['total equity', 'stockholders equity', 'stockholders equity'] },
{ key: 'net_cash_position', label: 'Net Cash Position', category: 'liquidity', order: 230, unit: 'currency', labelIncludes: ['net cash position'] }
];
const CASH_FLOW_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'operating_cash_flow', label: 'Operating Cash Flow', category: 'cash_flow', order: 10, unit: 'currency', localNames: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'], labelIncludes: ['operating cash flow'] },
{ key: 'capital_expenditures', label: 'Capital Expenditures', category: 'cash_flow', order: 20, unit: 'currency', localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid'], labelIncludes: ['capital expenditures', 'capital expenditure'] },
{ key: 'free_cash_flow', label: 'Free Cash Flow', category: 'cash_flow', order: 30, unit: 'currency', labelIncludes: ['free cash flow'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 40, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] },
{ key: 'acquisitions', label: 'Acquisitions', category: 'investing', order: 50, unit: 'currency', localNames: ['PaymentsToAcquireBusinessesNetOfCashAcquired'], labelIncludes: ['acquisitions'] },
{ key: 'share_repurchases', label: 'Share Repurchases', category: 'financing', order: 60, unit: 'currency', localNames: ['PaymentsForRepurchaseOfCommonStock', 'PaymentsForRepurchaseOfEquity'], labelIncludes: ['share repurchases', 'repurchase of common stock'] },
{ key: 'dividends_paid', label: 'Dividends Paid', category: 'financing', order: 70, unit: 'currency', localNames: ['PaymentsOfDividends', 'PaymentsOfDividendsCommonStock'], labelIncludes: ['dividends paid'] },
{ key: 'debt_issued', label: 'Debt Issued', category: 'financing', order: 80, unit: 'currency', localNames: ['ProceedsFromIssuanceOfLongTermDebt'], labelIncludes: ['debt issued'] },
{ key: 'debt_repaid', label: 'Debt Repaid', category: 'financing', order: 90, unit: 'currency', localNames: ['RepaymentsOfLongTermDebt', 'RepaymentsOfDebt'], labelIncludes: ['debt repaid', 'repayment of debt'] }
];
export const CANONICAL_ROW_DEFINITIONS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, CanonicalRowDefinition[]> = { export const CANONICAL_ROW_DEFINITIONS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, CanonicalRowDefinition[]> = {
income: INCOME_DEFINITIONS, income: INCOME_STATEMENT_METRIC_DEFINITIONS,
balance: BALANCE_DEFINITIONS, balance: BALANCE_SHEET_METRIC_DEFINITIONS,
cash_flow: CASH_FLOW_DEFINITIONS cash_flow: CASH_FLOW_STATEMENT_METRIC_DEFINITIONS
}; };

View File

@@ -4,6 +4,10 @@ import type {
RatioRow, RatioRow,
StandardizedFinancialRow StandardizedFinancialRow
} from '@/lib/types'; } from '@/lib/types';
import {
RATIO_CATEGORY_ORDER,
RATIO_DEFINITIONS
} from '@/lib/financial-metrics';
type StatementRowMap = { type StatementRowMap = {
income: StandardizedFinancialRow[]; income: StandardizedFinancialRow[];
@@ -11,50 +15,6 @@ type StatementRowMap = {
cashFlow: StandardizedFinancialRow[]; cashFlow: StandardizedFinancialRow[];
}; };
type RatioDefinition = {
key: string;
label: string;
category: string;
order: number;
unit: RatioRow['unit'];
denominatorKey: string | null;
};
const RATIO_DEFINITIONS: RatioDefinition[] = [
{ key: 'gross_margin', label: 'Gross Margin', category: 'margins', order: 10, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margins', order: 20, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'ebitda_margin', label: 'EBITDA Margin', category: 'margins', order: 30, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'net_margin', label: 'Net Margin', category: 'margins', order: 40, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'fcf_margin', label: 'FCF Margin', category: 'margins', order: 50, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'roa', label: 'ROA', category: 'returns', order: 60, unit: 'percent', denominatorKey: 'total_assets' },
{ key: 'roe', label: 'ROE', category: 'returns', order: 70, unit: 'percent', denominatorKey: 'total_equity' },
{ key: 'roic', label: 'ROIC', category: 'returns', order: 80, unit: 'percent', denominatorKey: 'average_invested_capital' },
{ key: 'roce', label: 'ROCE', category: 'returns', order: 90, unit: 'percent', denominatorKey: 'average_capital_employed' },
{ key: 'debt_to_equity', label: 'Debt to Equity', category: 'financial_health', order: 100, unit: 'ratio', denominatorKey: 'total_equity' },
{ key: 'net_debt_to_ebitda', label: 'Net Debt to EBITDA', category: 'financial_health', order: 110, unit: 'ratio', denominatorKey: 'ebitda' },
{ key: 'cash_to_debt', label: 'Cash to Debt', category: 'financial_health', order: 120, unit: 'ratio', denominatorKey: 'total_debt' },
{ key: 'current_ratio', label: 'Current Ratio', category: 'financial_health', order: 130, unit: 'ratio', denominatorKey: 'current_liabilities' },
{ key: 'revenue_per_share', label: 'Revenue per Share', category: 'per_share', order: 140, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'fcf_per_share', label: 'FCF per Share', category: 'per_share', order: 150, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'book_value_per_share', label: 'Book Value per Share', category: 'per_share', order: 160, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'revenue_yoy', label: 'Revenue YoY', category: 'growth', order: 170, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'net_income_yoy', label: 'Net Income YoY', category: 'growth', order: 180, unit: 'percent', denominatorKey: 'net_income' },
{ key: 'eps_yoy', label: 'EPS YoY', category: 'growth', order: 190, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: 'fcf_yoy', label: 'FCF YoY', category: 'growth', order: 200, unit: 'percent', denominatorKey: 'free_cash_flow' },
{ key: '3y_revenue_cagr', label: '3Y Revenue CAGR', category: 'growth', order: 210, unit: 'percent', denominatorKey: 'revenue' },
{ key: '5y_revenue_cagr', label: '5Y Revenue CAGR', category: 'growth', order: 220, unit: 'percent', denominatorKey: 'revenue' },
{ key: '3y_eps_cagr', label: '3Y EPS CAGR', category: 'growth', order: 230, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: '5y_eps_cagr', label: '5Y EPS CAGR', category: 'growth', order: 240, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: 'market_cap', label: 'Market Cap', category: 'valuation', order: 250, unit: 'currency', denominatorKey: null },
{ key: 'enterprise_value', label: 'Enterprise Value', category: 'valuation', order: 260, unit: 'currency', denominatorKey: null },
{ key: 'price_to_earnings', label: 'Price to Earnings', category: 'valuation', order: 270, unit: 'ratio', denominatorKey: 'diluted_eps' },
{ key: 'price_to_fcf', label: 'Price to FCF', category: 'valuation', order: 280, unit: 'ratio', denominatorKey: 'free_cash_flow' },
{ key: 'price_to_book', label: 'Price to Book', category: 'valuation', order: 290, unit: 'ratio', denominatorKey: 'total_equity' },
{ key: 'ev_to_sales', label: 'EV to Sales', category: 'valuation', order: 300, unit: 'ratio', denominatorKey: 'revenue' },
{ key: 'ev_to_ebitda', label: 'EV to EBITDA', category: 'valuation', order: 310, unit: 'ratio', denominatorKey: 'ebitda' },
{ key: 'ev_to_fcf', label: 'EV to FCF', category: 'valuation', order: 320, unit: 'ratio', denominatorKey: 'free_cash_flow' }
];
function valueFor(row: StandardizedFinancialRow | undefined, periodId: string) { function valueFor(row: StandardizedFinancialRow | undefined, periodId: string) {
return row?.values[periodId] ?? null; return row?.values[periodId] ?? null;
} }
@@ -358,12 +318,3 @@ export function buildRatioRows(input: {
return true; return true;
}); });
} }
export const RATIO_CATEGORY_ORDER = [
'margins',
'returns',
'financial_health',
'per_share',
'growth',
'valuation'
] as const;

View File

@@ -5,8 +5,8 @@ import type {
StructuredKpiRow, StructuredKpiRow,
TrendSeries TrendSeries
} from '@/lib/types'; } from '@/lib/types';
import { RATIO_CATEGORY_ORDER } from '@/lib/financial-metrics';
import { KPI_CATEGORY_ORDER } from '@/lib/server/financials/kpi-registry'; import { KPI_CATEGORY_ORDER } from '@/lib/server/financials/kpi-registry';
import { RATIO_CATEGORY_ORDER } from '@/lib/server/financials/ratios';
function toTrendSeriesRow(row: { function toTrendSeriesRow(row: {
key: string; key: string;

View File

@@ -1,148 +1,6 @@
import { and, desc, eq } from 'drizzle-orm'; export {
import type { createResearchJournalEntryCompat as createResearchJournalEntryRecord,
ResearchJournalEntry, deleteResearchJournalEntryCompat as deleteResearchJournalEntryRecord,
ResearchJournalEntryType listResearchJournalEntriesCompat as listResearchJournalEntries,
} from '@/lib/types'; updateResearchJournalEntryCompat as updateResearchJournalEntryRecord
import { db } from '@/lib/server/db'; } from '@/lib/server/repos/research-library';
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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,19 @@ export type User = {
export type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive'; export type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
export type CoveragePriority = 'low' | 'medium' | 'high'; export type CoveragePriority = 'low' | 'medium' | 'high';
export type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change'; export type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
export type NumberScaleUnit = 'thousands' | 'millions' | 'billions';
export type ResearchArtifactKind = 'filing' | 'ai_report' | 'note' | 'upload' | 'memo_snapshot' | 'status_change';
export type ResearchArtifactSource = 'system' | 'user';
export type ResearchVisibilityScope = 'private' | 'organization';
export type ResearchMemoRating = 'strong_buy' | 'buy' | 'hold' | 'sell';
export type ResearchMemoConviction = 'low' | 'medium' | 'high';
export type ResearchMemoSection =
| 'thesis'
| 'variant_view'
| 'catalysts'
| 'risks'
| 'disconfirming_evidence'
| 'next_actions';
export type WatchlistItem = { export type WatchlistItem = {
id: number; id: number;
@@ -188,6 +201,93 @@ export type ResearchJournalEntry = {
updated_at: string; updated_at: string;
}; };
export type ResearchArtifact = {
id: number;
user_id: string;
organization_id: string | null;
ticker: string;
accession_number: string | null;
kind: ResearchArtifactKind;
source: ResearchArtifactSource;
subtype: string | null;
title: string | null;
summary: string | null;
body_markdown: string | null;
search_text: string | null;
visibility_scope: ResearchVisibilityScope;
tags: string[];
metadata: Record<string, unknown> | null;
file_name: string | null;
mime_type: string | null;
file_size_bytes: number | null;
storage_path: string | null;
created_at: string;
updated_at: string;
linked_to_memo: boolean;
};
export type ResearchMemo = {
id: number;
user_id: string;
organization_id: string | null;
ticker: string;
rating: ResearchMemoRating | null;
conviction: ResearchMemoConviction | null;
time_horizon_months: number | null;
packet_title: string | null;
packet_subtitle: string | null;
thesis_markdown: string;
variant_view_markdown: string;
catalysts_markdown: string;
risks_markdown: string;
disconfirming_evidence_markdown: string;
next_actions_markdown: string;
created_at: string;
updated_at: string;
};
export type ResearchMemoEvidenceLink = {
id: number;
memo_id: number;
artifact_id: number;
section: ResearchMemoSection;
annotation: string | null;
sort_order: number;
created_at: string;
artifact: ResearchArtifact;
};
export type ResearchPacketSection = {
section: ResearchMemoSection;
title: string;
body_markdown: string;
evidence: ResearchMemoEvidenceLink[];
};
export type ResearchPacket = {
ticker: string;
companyName: string | null;
generated_at: string;
memo: ResearchMemo | null;
sections: ResearchPacketSection[];
};
export type ResearchLibraryResponse = {
artifacts: ResearchArtifact[];
availableTags: string[];
};
export type ResearchWorkspace = {
ticker: string;
companyName: string | null;
coverage: WatchlistItem | null;
latestFilingDate: string | null;
memo: ResearchMemo | null;
library: ResearchArtifact[];
packet: ResearchPacket;
availableTags: string[];
};
export type CompanyFinancialPoint = { export type CompanyFinancialPoint = {
filingDate: string; filingDate: string;
filingType: Filing['filing_type']; filingType: Filing['filing_type'];

View File

@@ -8,7 +8,7 @@
"dev:next": "bun --bun next dev --turbopack", "dev:next": "bun --bun next dev --turbopack",
"build": "bun --bun next build --turbopack", "build": "bun --bun next build --turbopack",
"start": "bun --bun next start", "start": "bun --bun next start",
"lint": "bun --bun tsc --noEmit", "lint": "bun x tsc --noEmit",
"e2e:prepare": "bun run scripts/e2e-prepare.ts", "e2e:prepare": "bun run scripts/e2e-prepare.ts",
"e2e:webserver": "bun run scripts/e2e-webserver.ts", "e2e:webserver": "bun run scripts/e2e-webserver.ts",
"workflow:setup": "workflow-postgres-setup", "workflow:setup": "workflow-postgres-setup",

View File

@@ -9,7 +9,9 @@ const MIGRATION_FILES = [
'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' '0006_coverage_journal_tracking.sql',
'0007_company_financial_bundles.sql',
'0008_research_workspace.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');