Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
This commit is contained in:
@@ -13,7 +13,15 @@ import {
|
||||
XAxis,
|
||||
YAxis
|
||||
} from 'recharts';
|
||||
import { BrainCircuit, ChartNoAxesCombined, RefreshCcw, Search } from 'lucide-react';
|
||||
import {
|
||||
BrainCircuit,
|
||||
ChartNoAxesCombined,
|
||||
NotebookPen,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
SquarePen,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,6 +29,11 @@ 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 {
|
||||
createResearchJournalEntry,
|
||||
deleteResearchJournalEntry,
|
||||
updateResearchJournalEntry
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
asNumber,
|
||||
formatCurrency,
|
||||
@@ -29,8 +42,14 @@ import {
|
||||
type NumberScaleUnit
|
||||
} from '@/lib/format';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import { companyAnalysisQueryOptions } from '@/lib/query/options';
|
||||
import type { CompanyAnalysis } from '@/lib/types';
|
||||
import {
|
||||
companyAnalysisQueryOptions,
|
||||
researchJournalQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import type {
|
||||
CompanyAnalysis,
|
||||
ResearchJournalEntry
|
||||
} from '@/lib/types';
|
||||
|
||||
type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly';
|
||||
|
||||
@@ -44,6 +63,18 @@ type FinancialSeriesPoint = {
|
||||
netMargin: number | null;
|
||||
};
|
||||
|
||||
type JournalFormState = {
|
||||
title: string;
|
||||
bodyMarkdown: string;
|
||||
accessionNumber: string;
|
||||
};
|
||||
|
||||
const EMPTY_JOURNAL_FORM: JournalFormState = {
|
||||
title: '',
|
||||
bodyMarkdown: '',
|
||||
accessionNumber: ''
|
||||
};
|
||||
|
||||
const FINANCIAL_PERIOD_FILTER_OPTIONS: Array<{ value: FinancialPeriodFilter; label: string }> = [
|
||||
{ value: 'quarterlyAndFiscalYearEnd', label: 'Quarterly + Fiscal Year End' },
|
||||
{ value: 'quarterlyOnly', label: 'Quarterly only' },
|
||||
@@ -70,6 +101,10 @@ function formatLongDate(value: string) {
|
||||
return format(new Date(value), 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return format(new Date(value), 'MMM dd, yyyy · HH:mm');
|
||||
}
|
||||
|
||||
function ratioPercent(numerator: number | null, denominator: number | null) {
|
||||
if (numerator === null || denominator === null || denominator === 0) {
|
||||
return null;
|
||||
@@ -78,6 +113,11 @@ function ratioPercent(numerator: number | null, denominator: number | null) {
|
||||
return (numerator / denominator) * 100;
|
||||
}
|
||||
|
||||
function normalizeTickerInput(value: string | null) {
|
||||
const normalized = value?.trim().toUpperCase() ?? '';
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function isFinancialSnapshotForm(
|
||||
filingType: CompanyAnalysis['filings'][number]['filing_type']
|
||||
): filingType is '10-K' | '10-Q' {
|
||||
@@ -120,22 +160,22 @@ function AnalysisPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch();
|
||||
const initialTicker = normalizeTickerInput(searchParams.get('ticker')) ?? 'MSFT';
|
||||
|
||||
const [tickerInput, setTickerInput] = useState('MSFT');
|
||||
const [ticker, setTicker] = useState('MSFT');
|
||||
const [tickerInput, setTickerInput] = useState(initialTicker);
|
||||
const [ticker, setTicker] = useState(initialTicker);
|
||||
const [analysis, setAnalysis] = useState<CompanyAnalysis | null>(null);
|
||||
const [journalEntries, setJournalEntries] = useState<ResearchJournalEntry[]>([]);
|
||||
const [journalLoading, setJournalLoading] = useState(true);
|
||||
const [journalForm, setJournalForm] = useState<JournalFormState>(EMPTY_JOURNAL_FORM);
|
||||
const [editingJournalId, setEditingJournalId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
|
||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||
|
||||
useEffect(() => {
|
||||
const fromQuery = searchParams.get('ticker');
|
||||
if (!fromQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = fromQuery.trim().toUpperCase();
|
||||
const normalized = normalizeTickerInput(searchParams.get('ticker'));
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -154,7 +194,7 @@ function AnalysisPageContent() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(options);
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setAnalysis(response.analysis);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load company analysis');
|
||||
@@ -164,11 +204,32 @@ function AnalysisPageContent() {
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const loadJournal = useCallback(async (symbol: string) => {
|
||||
const options = researchJournalQueryOptions(symbol);
|
||||
|
||||
if (!queryClient.getQueryData(options.queryKey)) {
|
||||
setJournalLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(options);
|
||||
setJournalEntries(response.entries);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load research journal');
|
||||
setJournalEntries([]);
|
||||
} finally {
|
||||
setJournalLoading(false);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void loadAnalysis(ticker);
|
||||
void Promise.all([
|
||||
loadAnalysis(ticker),
|
||||
loadJournal(ticker)
|
||||
]);
|
||||
}
|
||||
}, [isPending, isAuthenticated, ticker, loadAnalysis]);
|
||||
}, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]);
|
||||
|
||||
const priceSeries = useMemo(() => {
|
||||
return (analysis?.priceHistory ?? []).map((point) => ({
|
||||
@@ -207,6 +268,77 @@ function AnalysisPageContent() {
|
||||
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)';
|
||||
}, [financialValueScale]);
|
||||
|
||||
const resetJournalForm = useCallback(() => {
|
||||
setEditingJournalId(null);
|
||||
setJournalForm(EMPTY_JOURNAL_FORM);
|
||||
}, []);
|
||||
|
||||
const activeTicker = analysis?.company.ticker ?? ticker;
|
||||
|
||||
const saveJournalEntry = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||
if (!normalizedTicker) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingJournalId === null) {
|
||||
await createResearchJournalEntry({
|
||||
ticker: normalizedTicker,
|
||||
entryType: journalForm.accessionNumber.trim() ? 'filing_note' : 'note',
|
||||
title: journalForm.title.trim() || undefined,
|
||||
bodyMarkdown: journalForm.bodyMarkdown,
|
||||
accessionNumber: journalForm.accessionNumber.trim() || undefined
|
||||
});
|
||||
} else {
|
||||
await updateResearchJournalEntry(editingJournalId, {
|
||||
title: journalForm.title.trim() || undefined,
|
||||
bodyMarkdown: journalForm.bodyMarkdown
|
||||
});
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
await Promise.all([
|
||||
loadAnalysis(normalizedTicker),
|
||||
loadJournal(normalizedTicker)
|
||||
]);
|
||||
resetJournalForm();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save journal entry');
|
||||
}
|
||||
};
|
||||
|
||||
const beginEditJournalEntry = (entry: ResearchJournalEntry) => {
|
||||
setEditingJournalId(entry.id);
|
||||
setJournalForm({
|
||||
title: entry.title ?? '',
|
||||
bodyMarkdown: entry.body_markdown,
|
||||
accessionNumber: entry.accession_number ?? ''
|
||||
});
|
||||
};
|
||||
|
||||
const removeJournalEntry = async (entry: ResearchJournalEntry) => {
|
||||
try {
|
||||
await deleteResearchJournalEntry(entry.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(entry.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(entry.ticker) });
|
||||
await Promise.all([
|
||||
loadAnalysis(entry.ticker),
|
||||
loadJournal(entry.ticker)
|
||||
]);
|
||||
if (editingJournalId === entry.id) {
|
||||
resetJournalForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to delete journal entry');
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading analysis desk...</div>;
|
||||
}
|
||||
@@ -220,8 +352,13 @@ function AnalysisPageContent() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(ticker.trim().toUpperCase()) });
|
||||
void loadAnalysis(ticker);
|
||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) });
|
||||
void Promise.all([
|
||||
loadAnalysis(normalizedTicker),
|
||||
loadJournal(normalizedTicker)
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
@@ -243,6 +380,7 @@ function AnalysisPageContent() {
|
||||
>
|
||||
<Input
|
||||
value={tickerInput}
|
||||
aria-label="Analysis ticker"
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker (AAPL)"
|
||||
className="max-w-xs"
|
||||
@@ -252,14 +390,24 @@ function AnalysisPageContent() {
|
||||
Analyze
|
||||
</Button>
|
||||
{analysis ? (
|
||||
<Link
|
||||
href={`/filings?ticker=${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 filing stream
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
href={`/financials?ticker=${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 financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${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 filing stream
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</Panel>
|
||||
@@ -310,6 +458,101 @@ function AnalysisPageContent() {
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<Panel title="Coverage Workflow" subtitle="Coverage metadata shared with the Coverage page.">
|
||||
{analysis?.coverage ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Status</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.status}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Priority</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.priority}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Last Reviewed</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.last_reviewed_at ? formatDateTime(analysis.coverage.last_reviewed_at) : 'No research review recorded yet'}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Latest Filing</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.latest_filing_date ? formatLongDate(analysis.coverage.latest_filing_date) : 'No filing history loaded yet'}</p>
|
||||
</div>
|
||||
<Link href="/watchlist" className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||
Manage coverage
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">This company is not yet in your coverage list. Add it from Coverage to track workflow status and review cadence.</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Key Metrics" subtitle="Latest filing-level metrics used to anchor research.">
|
||||
<div className="grid grid-cols-1 gap-3 text-sm md:grid-cols-2">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Revenue</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.revenue, financialValueScale)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Net Income</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.netIncome, financialValueScale)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Assets</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(analysis?.keyMetrics.totalAssets, financialValueScale)}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Cash / Debt</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">
|
||||
{analysis?.keyMetrics.cash != null && analysis?.keyMetrics.debt != null
|
||||
? `${asScaledFinancialCurrency(analysis.keyMetrics.cash, financialValueScale)} / ${asScaledFinancialCurrency(analysis.keyMetrics.debt, financialValueScale)}`
|
||||
: 'n/a'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-[color:var(--terminal-muted)]">
|
||||
Reference date: {analysis?.keyMetrics.referenceDate ? formatLongDate(analysis.keyMetrics.referenceDate) : 'No financial filing selected'}.
|
||||
{' '}Net margin: {analysis && analysis.keyMetrics.netMargin !== null ? formatPercent(analysis.keyMetrics.netMargin) : 'n/a'}.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Latest Filing Snapshot" subtitle="Most recent filing and whether an AI memo already exists.">
|
||||
{analysis?.latestFilingSummary ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Filing</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">
|
||||
{analysis.latestFilingSummary.filingType} · {formatLongDate(analysis.latestFilingSummary.filingDate)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[color:var(--terminal-bright)]">
|
||||
{analysis.latestFilingSummary.summary ?? 'No AI summary stored yet for the most recent filing.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{analysis.latestFilingSummary.hasAnalysis ? (
|
||||
<Link
|
||||
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(analysis.latestFilingSummary.accessionNumber)}`}
|
||||
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)]"
|
||||
>
|
||||
Open latest memo
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
href={`/filings?ticker=${analysis.company.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)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No filing snapshot available yet for this company.</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<Panel title="Price History" subtitle="Weekly close over the last year.">
|
||||
{loading ? (
|
||||
@@ -480,11 +723,11 @@ function AnalysisPageContent() {
|
||||
<Panel title="AI Reports" subtitle="Generated filing analyses for this company.">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading AI reports...</p>
|
||||
) : !analysis || analysis.aiReports.length === 0 ? (
|
||||
) : !analysis || analysis.recentAiReports.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No AI reports generated yet. Run filing analysis from the filings stream.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{analysis.aiReports.map((report) => (
|
||||
{analysis.recentAiReports.map((report) => (
|
||||
<article key={report.accessionNumber} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -513,10 +756,122 @@ function AnalysisPageContent() {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_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."
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Body</label>
|
||||
<textarea
|
||||
value={journalForm.bodyMarkdown}
|
||||
aria-label="Journal body"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, bodyMarkdown: event.target.value }))}
|
||||
placeholder="Write your thesis update, questions, risks, and next steps..."
|
||||
className="min-h-[220px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">
|
||||
<NotebookPen className="size-4" />
|
||||
{editingJournalId === null ? 'Save note' : 'Update note'}
|
||||
</Button>
|
||||
{editingJournalId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetJournalForm}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Journal Timeline" subtitle={`${journalEntries.length} stored entries for ${activeTicker}.`}>
|
||||
{journalLoading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading journal 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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{journalEntries.map((entry) => {
|
||||
const canEdit = entry.entry_type !== 'status_change';
|
||||
|
||||
return (
|
||||
<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>
|
||||
{entry.accession_number ? (
|
||||
<Link
|
||||
href={`/filings?ticker=${activeTicker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(activeTicker)}
|
||||
onFocus={() => prefetchResearchTicker(activeTicker)}
|
||||
className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<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
|
||||
Analysis scope: price + filings + ai synthesis + research journal
|
||||
</div>
|
||||
</Panel>
|
||||
</AppShell>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, BrainCircuit, RefreshCcw } from 'lucide-react';
|
||||
import { ArrowLeft, BrainCircuit, NotebookPen, RefreshCcw } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
@@ -14,6 +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';
|
||||
|
||||
function formatFilingDate(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -44,6 +45,8 @@ export default function AnalysisReportPage() {
|
||||
const [report, setReport] = useState<CompanyAiReportDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingToJournal, setSavingToJournal] = useState(false);
|
||||
const [journalNotice, setJournalNotice] = useState<string | null>(null);
|
||||
|
||||
const loadReport = useCallback(async () => {
|
||||
if (!accessionNumber) {
|
||||
@@ -174,6 +177,49 @@ export default function AnalysisReportPage() {
|
||||
<BrainCircuit className="size-3.5" />
|
||||
Full text view
|
||||
</div>
|
||||
{journalNotice ? (
|
||||
<p className="mb-3 text-xs text-[color:var(--accent)]">{journalNotice}</p>
|
||||
) : null}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={savingToJournal}
|
||||
onClick={async () => {
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingToJournal(true);
|
||||
setJournalNotice(null);
|
||||
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
ticker: report.ticker,
|
||||
accessionNumber: report.accessionNumber,
|
||||
entryType: 'filing_note',
|
||||
title: `${report.filingType} AI memo`,
|
||||
bodyMarkdown: [
|
||||
`Stored AI memo for ${report.companyName} (${report.ticker}).`,
|
||||
`Accession: ${report.accessionNumber}`,
|
||||
'',
|
||||
report.summary
|
||||
].join('\n')
|
||||
});
|
||||
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.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save report to journal');
|
||||
} finally {
|
||||
setSavingToJournal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
{savingToJournal ? 'Saving...' : 'Add to journal'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||
{report.summary}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user