'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(); 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 ( Loading research workspace...}> ); } function ResearchPageContent() { const { isPending, isAuthenticated } = useAuthGuard(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); const { prefetchResearchTicker } = useLinkPrefetch(); const [workspace, setWorkspace] = useState(null); const [library, setLibrary] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [noteForm, setNoteForm] = useState(EMPTY_NOTE_FORM); const [memoForm, setMemoForm] = useState(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('thesis'); const [uploadFile, setUploadFile] = useState(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
Loading research workspace...
; } 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 ( {ticker ? ( 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 overview ) : null} )} > {!ticker ? (

This workspace is company-first by design and activates once a ticker is selected.

) : null} {ticker && workspace ? ( <>

Buy-Side Research Workspace

{workspace.companyName ?? workspace.ticker}

{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

{(workspace.coverage?.tags ?? []).map((tag) => ( {tag} ))}

Memo posture

{workspace.memo?.rating ? workspace.memo.rating.replace('_', ' ') : 'Unrated'}

Conviction: {workspace.memo?.conviction ?? 'unset'}

Research depth

{workspace.library.length} artifacts

{memoEvidenceCount} evidence links in the packet

{notice ? (

{notice}

) : null} {error ? (

{error}

) : null} {loading ? (

Loading research workspace...

) : ( <>
} >
setSearchInput(event.target.value)} placeholder="Keyword search research..." />
Access Model

All research artifacts are private to the authenticated user in this release. The data model is prepared for workspace scopes later.

} >
setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" /> setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />