-
-
setJournalForm((prev) => ({ ...prev, title: event.target.value }))}
- placeholder="Investment thesis checkpoint, risk note, follow-up..."
- />
+
+
+
Workspace focus
+
+ 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.
+
-
-
-
setJournalForm((prev) => ({ ...prev, accessionNumber: event.target.value }))}
- placeholder="0000000000-26-000001"
- />
+
+
+
Stored research entries
+
{journalEntries.length}
+
+
+
Latest update
+
{journalEntries[0] ? formatDateTime(journalEntries[0].updated_at) : 'No research activity yet'}
+
-
-
-
-
-
- {editingJournalId !== null ? (
-
- ) : null}
-
-
+
-
+
{journalLoading ? (
- Loading journal entries...
+ Loading research entries...
) : journalEntries.length === 0 ? (
- No journal notes yet. Start a thesis log or attach a filing note from the filings stream.
+ No research saved yet. Use AI memo saves from reports or open the Research workspace to start building the thesis.
) : (
- {journalEntries.map((entry) => {
- const canEdit = entry.entry_type !== 'status_change';
-
- return (
-
-
-
-
- {entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
-
-
- {entry.title ?? 'Untitled entry'}
-
-
- {entry.accession_number ? (
-
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
-
- ) : null}
+ {journalEntries.slice(0, 4).map((entry) => (
+
+
+
+
+ {entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
+
+
{entry.title ?? 'Untitled entry'}
-
{entry.body_markdown}
- {canEdit ? (
-
-
-
-
- ) : null}
-
- );
- })}
+
prefetchResearchTicker(activeTicker)}
+ onFocus={() => prefetchResearchTicker(activeTicker)}
+ className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
+ >
+ Open workspace
+
+
+ {entry.body_markdown}
+
+ ))}
)}
@@ -871,7 +838,7 @@ function AnalysisPageContent() {
- Analysis scope: price + filings + ai synthesis + research journal
+ Analysis scope: price + filings + ai synthesis + research workspace
diff --git a/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx b/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx
index 7a3ca51..c2c64b5 100644
--- a/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx
+++ b/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx
@@ -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() {
Back to filings
+ 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)]"
+ >
+
+ Open research
+
@@ -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);
}
}}
>
- {savingToJournal ? 'Saving...' : 'Add to journal'}
+ {savingToJournal ? 'Saving...' : 'Save to library'}
diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts
index 462b38a..70c07e9 100644
--- a/app/api/[[...slugs]]/route.ts
+++ b/app/api/[[...slugs]]/route.ts
@@ -1,8 +1,25 @@
import { app } from '@/lib/server/api/app';
-export const GET = app.fetch;
-export const POST = app.fetch;
-export const PATCH = app.fetch;
-export const PUT = app.fetch;
-export const DELETE = app.fetch;
-export const OPTIONS = app.fetch;
+export async function GET(request: Request) {
+ return await app.fetch(request);
+}
+
+export async function POST(request: Request) {
+ return await app.fetch(request);
+}
+
+export async function PATCH(request: Request) {
+ return await app.fetch(request);
+}
+
+export async function PUT(request: Request) {
+ return await app.fetch(request);
+}
+
+export async function DELETE(request: Request) {
+ return await app.fetch(request);
+}
+
+export async function OPTIONS(request: Request) {
+ return await app.fetch(request);
+}
diff --git a/app/filings/page.tsx b/app/filings/page.tsx
index fa75ddb..fe01512 100644
--- a/app/filings/page.tsx
+++ b/app/filings/page.tsx
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import {
- createResearchJournalEntry,
+ createResearchArtifact,
queueFilingAnalysis,
queueFilingSync
} from '@/lib/api';
@@ -202,27 +202,39 @@ function FilingsPageContent() {
}
};
- const addToJournal = async (filing: Filing) => {
+ const saveToLibrary = async (filing: Filing) => {
try {
- await createResearchJournalEntry({
+ await createResearchArtifact({
ticker: filing.ticker,
+ kind: 'filing',
+ source: 'system',
+ subtype: 'filing_snapshot',
accessionNumber: filing.accession_number,
- entryType: 'filing_note',
- title: `${filing.filing_type} filing note`,
+ title: `${filing.filing_type} filing snapshot`,
+ summary: filing.analysis?.text ?? filing.analysis?.legacyInsights ?? `Captured filing ${filing.accession_number}.`,
bodyMarkdown: [
`Captured filing note for ${filing.company_name} (${filing.ticker}).`,
`Filed: ${formatFilingDate(filing.filing_date)}`,
`Accession: ${filing.accession_number}`,
'',
filing.analysis?.text ?? filing.analysis?.legacyInsights ?? 'Follow up on this filing from the stream.'
- ].join('\n')
+ ].join('\n'),
+ metadata: {
+ filingType: filing.filing_type,
+ filingDate: filing.filing_date,
+ filingUrl: filing.filing_url,
+ submissionUrl: filing.submission_url ?? null,
+ primaryDocument: filing.primary_document ?? null
+ }
});
+ void queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(filing.ticker) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(filing.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(filing.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(filing.ticker) });
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
- setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} journal.`);
+ setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} research library.`);
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to add filing to journal');
+ setError(err instanceof Error ? err.message : 'Failed to save filing to library');
}
};
@@ -411,11 +423,11 @@ function FilingsPageContent() {
{hasAnalysis ? (
{financials ? (
- prefetchResearchTicker(financials.company.ticker)}
- onFocus={() => prefetchResearchTicker(financials.company.ticker)}
- className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
- >
- Open analysis
-
+ <>
+ prefetchResearchTicker(financials.company.ticker)}
+ onFocus={() => prefetchResearchTicker(financials.company.ticker)}
+ className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
+ >
+ Open analysis
+
+ prefetchResearchTicker(financials.company.ticker)}
+ onFocus={() => prefetchResearchTicker(financials.company.ticker)}
+ className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
+ >
+ Open graphing
+
+ >
) : null}
diff --git a/app/graphing/page.tsx b/app/graphing/page.tsx
new file mode 100644
index 0000000..5819bb6
--- /dev/null
+++ b/app/graphing/page.tsx
@@ -0,0 +1,664 @@
+'use client';
+
+import { useQueryClient } from '@tanstack/react-query';
+import { format } from 'date-fns';
+import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
+import Link from 'next/link';
+import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { BarChart3, RefreshCcw, Search, X } from 'lucide-react';
+import { AppShell } from '@/components/shell/app-shell';
+import { Button } from '@/components/ui/button';
+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 {
+ GRAPH_CADENCE_OPTIONS,
+ GRAPH_CHART_OPTIONS,
+ GRAPH_SCALE_OPTIONS,
+ GRAPH_SURFACE_LABELS,
+ buildGraphingHref,
+ getGraphMetricDefinition,
+ metricsForSurfaceAndCadence,
+ normalizeGraphTickers,
+ parseGraphingParams,
+ resolveGraphMetric,
+ serializeGraphingParams,
+ type GraphChartKind,
+ type GraphMetricDefinition,
+ type GraphingUrlState
+} from '@/lib/graphing/catalog';
+import {
+ buildGraphingComparisonData,
+ type GraphingChartDatum,
+ type GraphingFetchResult,
+ type GraphingLatestValueRow,
+ type GraphingSeriesPoint
+} from '@/lib/graphing/series';
+import { ApiError } from '@/lib/api';
+import {
+ formatCurrencyByScale,
+ formatPercent,
+ type NumberScaleUnit
+} from '@/lib/format';
+import type {
+ FinancialCadence,
+ FinancialUnit
+} from '@/lib/types';
+import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
+import { cn } from '@/lib/utils';
+
+const CHART_COLORS = ['#68ffd5', '#5fd3ff', '#ffd08a', '#ff8a8a', '#c39bff'] as const;
+const CHART_MUTED = '#b4ced9';
+const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
+const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
+const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
+
+type TooltipEntry = {
+ dataKey?: string | number;
+ color?: string;
+ value?: number | string | null;
+ payload?: GraphingChartDatum;
+};
+
+function formatLongDate(value: string | null) {
+ if (!value) {
+ return 'n/a';
+ }
+
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return 'n/a';
+ }
+
+ return format(parsed, 'MMM dd, yyyy');
+}
+
+function formatShortDate(value: string) {
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return value;
+ }
+
+ return format(parsed, 'MMM yyyy');
+}
+
+function formatMetricValue(
+ value: number | null | undefined,
+ unit: FinancialUnit,
+ scale: NumberScaleUnit
+) {
+ if (value === null || value === undefined) {
+ return 'n/a';
+ }
+
+ switch (unit) {
+ case 'currency':
+ return formatCurrencyByScale(value, scale);
+ case 'percent':
+ return formatPercent(value * 100);
+ case 'ratio':
+ return `${value.toFixed(2)}x`;
+ case 'shares':
+ case 'count':
+ return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(value);
+ default:
+ return String(value);
+ }
+}
+
+function formatChangeValue(
+ value: number | null,
+ unit: FinancialUnit,
+ scale: NumberScaleUnit
+) {
+ if (value === null) {
+ return 'n/a';
+ }
+
+ if (unit === 'percent') {
+ const signed = value >= 0 ? '+' : '';
+ return `${signed}${formatPercent(value * 100)}`;
+ }
+
+ if (unit === 'ratio') {
+ const signed = value >= 0 ? '+' : '';
+ return `${signed}${value.toFixed(2)}x`;
+ }
+
+ if (unit === 'currency') {
+ const formatted = formatCurrencyByScale(Math.abs(value), scale);
+ return value >= 0 ? `+${formatted}` : `-${formatted}`;
+ }
+
+ const formatted = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(Math.abs(value));
+ return value >= 0 ? `+${formatted}` : `-${formatted}`;
+}
+
+function tickerPillClass(disabled?: boolean) {
+ return cn(
+ 'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs',
+ disabled
+ ? 'border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)]'
+ : 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]'
+ );
+}
+
+function ControlSection(props: {
+ label: string;
+ value: T;
+ options: Array<{ value: T; label: string }>;
+ onChange: (value: T) => void;
+ ariaLabel: string;
+}) {
+ return (
+
+
{props.label}
+
+ {props.options.map((option) => (
+
+ ))}
+
+
+ );
+}
+
+function ComparisonTooltip(props: {
+ active?: boolean;
+ payload?: TooltipEntry[];
+ metric: GraphMetricDefinition;
+ scale: NumberScaleUnit;
+}) {
+ if (!props.active || !props.payload || props.payload.length === 0) {
+ return null;
+ }
+
+ const datum = props.payload[0]?.payload;
+ if (!datum) {
+ return null;
+ }
+
+ const entries = props.payload
+ .filter((entry) => typeof entry.dataKey === 'string')
+ .map((entry) => {
+ const ticker = entry.dataKey as string;
+ const meta = datum[`meta__${ticker}`] as GraphingSeriesPoint | undefined;
+
+ return {
+ ticker,
+ color: entry.color ?? CHART_MUTED,
+ value: typeof entry.value === 'number' ? entry.value : null,
+ meta
+ };
+ });
+
+ return (
+
+
+ {formatLongDate(entries[0]?.meta?.dateKey ?? null)}
+
+
+ {entries.map((entry) => (
+
+
+
+
+ {entry.ticker}
+
+
+ {formatMetricValue(entry.value, props.metric.unit, props.scale)}
+
+
+ {entry.meta ? (
+
+ {entry.meta.filingType} · {entry.meta.periodLabel}
+
+ ) : null}
+
+ ))}
+
+
+ );
+}
+
+export default function GraphingPage() {
+ return (
+ Loading graphing desk...
}>
+