From 52136271d347c88abbca8471ebe50af1e5a99625 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sat, 7 Mar 2026 09:51:18 -0500 Subject: [PATCH] Implement fiscal-style research MVP flows --- app/analysis/page.tsx | 409 ++++++++++++- .../[ticker]/[accessionNumber]/page.tsx | 48 +- app/filings/page.tsx | 52 +- app/financials/page.tsx | 93 ++- app/page.tsx | 8 +- app/portfolio/page.tsx | 174 ++++-- app/watchlist/page.tsx | 563 ++++++++++++++---- components/shell/app-shell.tsx | 4 +- drizzle/0006_coverage_journal_tracking.sql | 41 ++ drizzle/meta/_journal.json | 7 + e2e/research-mvp.spec.ts | 189 ++++++ lib/api.ts | 109 ++++ lib/query/keys.ts | 1 + lib/query/options.ts | 11 + lib/server/api/app.ts | 342 ++++++++++- .../api/task-workflow-hybrid.e2e.test.ts | 268 ++++++++- lib/server/db/index.test.ts | 9 + lib/server/db/index.ts | 49 +- lib/server/db/schema.ts | 32 +- lib/server/financial-taxonomy.ts | 84 +++ lib/server/repos/filings.ts | 31 +- lib/server/repos/holdings.ts | 74 ++- lib/server/repos/research-journal.ts | 148 +++++ lib/server/repos/watchlist.ts | 147 ++++- lib/types.ts | 66 ++ scripts/e2e-prepare.ts | 3 +- 26 files changed, 2719 insertions(+), 243 deletions(-) create mode 100644 drizzle/0006_coverage_journal_tracking.sql create mode 100644 e2e/research-mvp.spec.ts create mode 100644 lib/server/repos/research-journal.ts diff --git a/app/analysis/page.tsx b/app/analysis/page.tsx index 3cf88a5..ac6f9b8 100644 --- a/app/analysis/page.tsx +++ b/app/analysis/page.tsx @@ -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(null); + const [journalEntries, setJournalEntries] = useState([]); + const [journalLoading, setJournalLoading] = useState(true); + const [journalForm, setJournalForm] = useState(EMPTY_JOURNAL_FORM); + const [editingJournalId, setEditingJournalId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [financialPeriodFilter, setFinancialPeriodFilter] = useState('quarterlyAndFiscalYearEnd'); const [financialValueScale, setFinancialValueScale] = useState('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) => { + 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
Loading analysis desk...
; } @@ -220,8 +352,13 @@ function AnalysisPageContent() { {analysis ? ( - 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 - + <> + prefetchResearchTicker(analysis.company.ticker)} + onFocus={() => prefetchResearchTicker(analysis.company.ticker)} + className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" + > + Open financials + + 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 + + ) : null} @@ -310,6 +458,101 @@ function AnalysisPageContent() { +
+ + {analysis?.coverage ? ( +
+
+
+

Status

+

{analysis.coverage.status}

+
+
+

Priority

+

{analysis.coverage.priority}

+
+
+
+

Last Reviewed

+

{analysis.coverage.last_reviewed_at ? formatDateTime(analysis.coverage.last_reviewed_at) : 'No research review recorded yet'}

+
+
+

Latest Filing

+

{analysis.coverage.latest_filing_date ? formatLongDate(analysis.coverage.latest_filing_date) : 'No filing history loaded yet'}

+
+ + Manage coverage + +
+ ) : ( +

This company is not yet in your coverage list. Add it from Coverage to track workflow status and review cadence.

+ )} +
+ + +
+
+

Revenue

+

{asScaledFinancialCurrency(analysis?.keyMetrics.revenue, financialValueScale)}

+
+
+

Net Income

+

{asScaledFinancialCurrency(analysis?.keyMetrics.netIncome, financialValueScale)}

+
+
+

Assets

+

{asScaledFinancialCurrency(analysis?.keyMetrics.totalAssets, financialValueScale)}

+
+
+

Cash / Debt

+

+ {analysis?.keyMetrics.cash != null && analysis?.keyMetrics.debt != null + ? `${asScaledFinancialCurrency(analysis.keyMetrics.cash, financialValueScale)} / ${asScaledFinancialCurrency(analysis.keyMetrics.debt, financialValueScale)}` + : 'n/a'} +

+
+
+

+ 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'}. +

+
+ + + {analysis?.latestFilingSummary ? ( +
+
+

Filing

+

+ {analysis.latestFilingSummary.filingType} · {formatLongDate(analysis.latestFilingSummary.filingDate)} +

+
+

+ {analysis.latestFilingSummary.summary ?? 'No AI summary stored yet for the most recent filing.'} +

+
+ {analysis.latestFilingSummary.hasAnalysis ? ( + + Open latest memo + + ) : null} + + Open filing stream + +
+
+ ) : ( +

No filing snapshot available yet for this company.

+ )} +
+
+
{loading ? ( @@ -480,11 +723,11 @@ function AnalysisPageContent() { {loading ? (

Loading AI reports...

- ) : !analysis || analysis.aiReports.length === 0 ? ( + ) : !analysis || analysis.recentAiReports.length === 0 ? (

No AI reports generated yet. Run filing analysis from the filings stream.

) : (
- {analysis.aiReports.map((report) => ( + {analysis.recentAiReports.map((report) => (
@@ -513,10 +756,122 @@ function AnalysisPageContent() { )} +
+ +
+
+ + setJournalForm((prev) => ({ ...prev, title: event.target.value }))} + placeholder="Investment thesis checkpoint, risk note, follow-up..." + /> +
+
+ + setJournalForm((prev) => ({ ...prev, accessionNumber: event.target.value }))} + placeholder="0000000000-26-000001" + /> +
+
+ +