Merge branch 't3code/expand-research-management-plan'

# Conflicts:
#	app/analysis/page.tsx
#	app/watchlist/page.tsx
#	components/shell/app-shell.tsx
#	lib/api.ts
#	lib/query/options.ts
#	lib/server/api/app.ts
#	lib/server/db/index.test.ts
#	lib/server/db/index.ts
#	lib/server/db/schema.ts
#	lib/server/repos/research-journal.ts
#	lib/types.ts
This commit is contained in:
2026-03-07 20:39:49 -05:00
38 changed files with 5533 additions and 427 deletions

View File

@@ -16,6 +16,7 @@ import {
import {
BrainCircuit,
ChartNoAxesCombined,
NotebookTabs,
NotebookPen,
RefreshCcw,
Search,
@@ -29,6 +30,7 @@ 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 { buildGraphingHref } from '@/lib/graphing/catalog';
import {
createResearchJournalEntry,
deleteResearchJournalEntry,
@@ -440,6 +442,14 @@ function AnalysisPageContent() {
>
Open filing stream
</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}
</form>
@@ -855,123 +865,70 @@ function AnalysisPageContent() {
)}
</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
title={editingJournalId === null ? 'Research Journal' : 'Edit Journal Entry'}
subtitle="Private markdown notes for this company. Linked filing notes update the coverage review timestamp."
title="Research Summary"
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>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Title</label>
<Input
value={journalForm.title}
aria-label="Journal title"
onChange={(event) => setJournalForm((prev) => ({ ...prev, title: event.target.value }))}
placeholder="Investment thesis checkpoint, risk note, follow-up..."
/>
<div className="space-y-4">
<div 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)]">Workspace focus</p>
<p className="mt-2 text-sm leading-6 text-[color:var(--terminal-bright)]">
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.
</p>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Linked Filing (optional)</label>
<Input
value={journalForm.accessionNumber}
aria-label="Journal linked filing"
onChange={(event) => setJournalForm((prev) => ({ ...prev, accessionNumber: event.target.value }))}
placeholder="0000000000-26-000001"
/>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div 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)]">Stored research entries</p>
<p className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">{journalEntries.length}</p>
</div>
<div 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)]">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>
<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" className="w-full sm:w-auto">
<NotebookPen className="size-4" />
{editingJournalId === null ? 'Save note' : 'Update note'}
</Button>
{editingJournalId !== null ? (
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetJournalForm}>
Cancel edit
</Button>
) : null}
</div>
</form>
</div>
</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 ? (
<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 ? (
<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">
{journalEntries.map((entry) => {
const canEdit = entry.entry_type !== 'status_change';
return (
<article
key={entry.id}
ref={(node) => {
journalEntryRefs.current.set(entry.id, node);
}}
className={`rounded-xl border bg-[color:var(--panel-soft)] p-4 transition ${
highlightedJournalId === entry.id
? 'border-[color:var(--line-strong)] shadow-[0_0_0_1px_rgba(0,255,180,0.14),0_0_28px_rgba(0,255,180,0.16)]'
: 'border-[color:var(--line-weak)]'
}`}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
{entry.title ?? 'Untitled entry'}
</h4>
</div>
{entry.accession_number ? (
<Link
href={`/filings?ticker=${activeTicker}`}
onMouseEnter={() => prefetchResearchTicker(activeTicker)}
onFocus={() => prefetchResearchTicker(activeTicker)}
className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Open filing stream
</Link>
) : null}
{journalEntries.slice(0, 4).map((entry) => (
<article key={entry.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{entry.title ?? 'Untitled entry'}</h4>
</div>
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{entry.body_markdown}</p>
{canEdit ? (
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => beginEditJournalEntry(entry)}
>
<SquarePen className="size-3" />
Edit
</Button>
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={() => {
void removeJournalEntry(entry);
}}
>
<Trash2 className="size-3" />
Delete
</Button>
</div>
) : null}
</article>
);
})}
<Link
href={`/research?ticker=${encodeURIComponent(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 workspace
</Link>
</div>
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{entry.body_markdown}</p>
</article>
))}
</div>
)}
</Panel>
@@ -980,7 +937,7 @@ function AnalysisPageContent() {
<Panel>
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
<ChartNoAxesCombined className="size-4" />
Analysis scope: price + filings + ai synthesis + research journal
Analysis scope: price + filings + ai synthesis + research workspace
</div>
</Panel>
</AppShell>

View File

@@ -14,7 +14,7 @@ import { aiReportQueryOptions } from '@/lib/query/options';
import type { CompanyAiReportDetail } from '@/lib/types';
import { Button } from '@/components/ui/button';
import { Panel } from '@/components/ui/panel';
import { createResearchJournalEntry } from '@/lib/api';
import { createResearchArtifact } from '@/lib/api';
function formatFilingDate(value: string) {
const date = new Date(value);
@@ -87,6 +87,7 @@ export default function AnalysisReportPage() {
const resolvedTicker = report?.ticker ?? tickerFromRoute;
const analysisHref = resolvedTicker ? `/analysis?ticker=${encodeURIComponent(resolvedTicker)}` : '/analysis';
const researchHref = resolvedTicker ? `/research?ticker=${encodeURIComponent(resolvedTicker)}` : '/research';
const filingsHref = resolvedTicker ? `/filings?ticker=${encodeURIComponent(resolvedTicker)}` : '/filings';
return (
@@ -135,6 +136,15 @@ export default function AnalysisReportPage() {
<ArrowLeft className="size-3" />
Back to filings
</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>
</Panel>
@@ -193,11 +203,14 @@ export default function AnalysisReportPage() {
setJournalNotice(null);
try {
await createResearchJournalEntry({
await createResearchArtifact({
ticker: report.ticker,
kind: 'ai_report',
source: 'system',
subtype: 'filing_analysis',
accessionNumber: report.accessionNumber,
entryType: 'filing_note',
title: `${report.filingType} AI memo`,
summary: report.summary,
bodyMarkdown: [
`Stored AI memo for ${report.companyName} (${report.ticker}).`,
`Accession: ${report.accessionNumber}`,
@@ -205,19 +218,21 @@ export default function AnalysisReportPage() {
report.summary
].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.companyAnalysis(report.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
setJournalNotice('Saved to the company research journal.');
setJournalNotice('Saved to the company research library.');
} 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 {
setSavingToJournal(false);
}
}}
>
<NotebookPen className="size-4" />
{savingToJournal ? 'Saving...' : 'Add to journal'}
{savingToJournal ? 'Saving...' : 'Save to library'}
</Button>
</div>
<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';
export const GET = app.fetch;
export const POST = app.fetch;
export const PATCH = app.fetch;
export const PUT = app.fetch;
export const DELETE = app.fetch;
export const OPTIONS = app.fetch;
export async function GET(request: Request) {
return await app.fetch(request);
}
export async function POST(request: Request) {
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 { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import {
createResearchJournalEntry,
createResearchArtifact,
queueFilingAnalysis,
queueFilingSync
} from '@/lib/api';
@@ -202,27 +202,39 @@ function FilingsPageContent() {
}
};
const addToJournal = async (filing: Filing) => {
const saveToLibrary = async (filing: Filing) => {
try {
await createResearchJournalEntry({
await createResearchArtifact({
ticker: filing.ticker,
kind: 'filing',
source: 'system',
subtype: 'filing_snapshot',
accessionNumber: filing.accession_number,
entryType: 'filing_note',
title: `${filing.filing_type} filing note`,
title: `${filing.filing_type} filing snapshot`,
summary: filing.analysis?.text ?? filing.analysis?.legacyInsights ?? `Captured filing ${filing.accession_number}.`,
bodyMarkdown: [
`Captured filing note for ${filing.company_name} (${filing.ticker}).`,
`Filed: ${formatFilingDate(filing.filing_date)}`,
`Accession: ${filing.accession_number}`,
'',
filing.analysis?.text ?? filing.analysis?.legacyInsights ?? 'Follow up on this filing from the stream.'
].join('\n')
].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.companyAnalysis(filing.ticker) });
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) {
setError(err instanceof Error ? err.message : 'Failed to add filing to journal');
setError(err instanceof Error ? err.message : 'Failed to save filing to library');
}
};
@@ -420,11 +432,11 @@ function FilingsPageContent() {
</Button>
<Button
variant="ghost"
onClick={() => void addToJournal(filing)}
onClick={() => void saveToLibrary(filing)}
className="px-2 py-1 text-xs"
>
<NotebookPen className="size-3" />
Add to journal
Save to library
</Button>
{hasAnalysis ? (
<Link
@@ -498,7 +510,7 @@ function FilingsPageContent() {
</Button>
<Button
variant="ghost"
onClick={() => void addToJournal(filing)}
onClick={() => void saveToLibrary(filing)}
className="px-2 py-1 text-xs"
>
<NotebookPen className="size-3" />

View File

@@ -40,6 +40,7 @@ import {
formatPercent,
type NumberScaleUnit
} from '@/lib/format';
import { buildGraphingHref } from '@/lib/graphing/catalog';
import { queryKeys } from '@/lib/query/keys';
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
import type {
@@ -630,14 +631,24 @@ function FinancialsPageContent() {
Load Financials
</Button>
{financials ? (
<Link
href={`/analysis?ticker=${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 analysis
</Link>
<>
<Link
href={`/analysis?ticker=${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 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}
</form>
</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,
queuePriceRefresh
} from '@/lib/api';
import { buildGraphingHref } from '@/lib/graphing/catalog';
import type { PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
import { formatCompactCurrency, formatCurrency, formatPercent } from '@/lib/format';
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="mt-2 text-sm text-[color:var(--terminal-bright)]">Focus on multi-period filing metrics, margins, leverage, and balance sheet composition.</p>
</Link>
<Link className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]" 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
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"

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

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