832 lines
40 KiB
TypeScript
832 lines
40 KiB
TypeScript
'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(29,31,36,0.97),rgba(34,37,42,0.9)_45%,rgba(42,46,52,0.94))]">
|
|
<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.03)] 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.04)] 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.04)] 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.03)] 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_var(--focus-ring)]"
|
|
/>
|
|
<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_var(--focus-ring)]"
|
|
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" />;
|
|
}
|