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:
@@ -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>
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
Reference in New Issue
Block a user