Rebuild company overview analysis page

This commit is contained in:
2026-03-12 20:39:30 -04:00
parent b9a1d8ba40
commit ba385586bc
29 changed files with 2040 additions and 888 deletions

View File

@@ -1,157 +1,32 @@
'use client';
import { useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { format } from 'date-fns';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis
} from 'recharts';
import {
BrainCircuit,
ChartNoAxesCombined,
NotebookTabs,
NotebookPen,
RefreshCcw,
Search,
SquarePen,
Trash2
} from 'lucide-react';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { AppShell } from '@/components/shell/app-shell';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AnalysisToolbar } from '@/components/analysis/analysis-toolbar';
import { BullBearPanel } from '@/components/analysis/bull-bear-panel';
import { CompanyOverviewCard } from '@/components/analysis/company-overview-card';
import { CompanyProfileFactsTable } from '@/components/analysis/company-profile-facts-table';
import { PriceHistoryCard } from '@/components/analysis/price-history-card';
import { RecentDevelopmentsSection } from '@/components/analysis/recent-developments-section';
import { ValuationFactsTable } from '@/components/analysis/valuation-facts-table';
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,
updateResearchJournalEntry
} from '@/lib/api';
import {
asNumber,
formatCurrency,
formatCurrencyByScale,
formatPercent,
type NumberScaleUnit
} from '@/lib/format';
import { queryKeys } from '@/lib/query/keys';
import {
companyAnalysisQueryOptions,
researchJournalQueryOptions
} from '@/lib/query/options';
import type {
CompanyAnalysis,
ResearchJournalEntry
} from '@/lib/types';
type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly';
type FinancialSeriesPoint = {
filingDate: string;
filingType: '10-K' | '10-Q';
periodLabel: 'Quarter End' | 'Fiscal Year End';
revenue: number | null;
netIncome: number | null;
assets: number | null;
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' },
{ value: 'fiscalYearEndOnly', label: 'Fiscal Year End only' }
];
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
{ value: 'thousands', label: 'Thousands (K)' },
{ value: 'millions', label: 'Millions (M)' },
{ value: 'billions', label: 'Billions (B)' }
];
const CHART_TEXT = '#f3f5f7';
const CHART_MUTED = '#a1a9b3';
const CHART_GRID = 'rgba(196, 202, 211, 0.18)';
const CHART_TOOLTIP_BG = 'rgba(31, 34, 39, 0.96)';
const CHART_TOOLTIP_BORDER = 'rgba(220, 226, 234, 0.24)';
function formatShortDate(value: string) {
return format(new Date(value), 'MMM yyyy');
}
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;
}
return (numerator / denominator) * 100;
}
import { companyAnalysisQueryOptions } from '@/lib/query/options';
import type { CompanyAnalysis } from '@/lib/types';
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' {
return filingType === '10-K' || filingType === '10-Q';
}
function includesFinancialPeriod(filingType: '10-K' | '10-Q', filter: FinancialPeriodFilter) {
if (filter === 'quarterlyOnly') {
return filingType === '10-Q';
}
if (filter === 'fiscalYearEndOnly') {
return filingType === '10-K';
}
return true;
}
function asScaledFinancialCurrency(
value: number | null | undefined,
scale: NumberScaleUnit
) {
if (value === null || value === undefined) {
return 'n/a';
}
return formatCurrencyByScale(value, scale);
}
export default function AnalysisPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading analysis desk...</div>}>
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading overview...</div>}>
<AnalysisPageContent />
</Suspense>
);
@@ -161,22 +36,14 @@ function AnalysisPageContent() {
const { isPending, isAuthenticated } = useAuthGuard();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch();
const { prefetchResearchTicker } = useLinkPrefetch();
const initialTicker = normalizeTickerInput(searchParams.get('ticker')) ?? '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 [highlightedJournalId, setHighlightedJournalId] = 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');
const journalEntryRefs = useRef(new Map<number, HTMLElement | null>());
useEffect(() => {
const normalized = normalizeTickerInput(searchParams.get('ticker'));
@@ -186,8 +53,6 @@ function AnalysisPageContent() {
setTickerInput(normalized);
setTicker(normalized);
const journalId = Number(searchParams.get('journalId'));
setHighlightedJournalId(Number.isInteger(journalId) && journalId > 0 ? journalId : null);
}, [searchParams]);
const loadAnalysis = useCallback(async (symbol: string) => {
@@ -203,745 +68,97 @@ function AnalysisPageContent() {
const response = await queryClient.fetchQuery(options);
setAnalysis(response.analysis);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load company analysis');
setError(err instanceof Error ? err.message : 'Unable to load company overview');
setAnalysis(null);
} finally {
setLoading(false);
}
}, [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 Promise.all([
loadAnalysis(ticker),
loadJournal(ticker)
]);
void loadAnalysis(ticker);
}
}, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]);
useEffect(() => {
if (!highlightedJournalId) {
return;
}
const node = journalEntryRefs.current.get(highlightedJournalId);
if (!node) {
return;
}
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
const timeoutId = window.setTimeout(() => {
setHighlightedJournalId((current) => (current === highlightedJournalId ? null : current));
}, 2200);
return () => {
window.clearTimeout(timeoutId);
};
}, [highlightedJournalId, journalEntries]);
const priceSeries = useMemo(() => {
return (analysis?.priceHistory ?? []).map((point) => ({
...point,
label: formatShortDate(point.date)
}));
}, [analysis?.priceHistory]);
const financialSeries = useMemo<FinancialSeriesPoint[]>(() => {
return (analysis?.financials ?? [])
.filter((item): item is CompanyAnalysis['financials'][number] & { filingType: '10-K' | '10-Q' } => {
return isFinancialSnapshotForm(item.filingType);
})
.slice()
.sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate))
.map((item) => ({
filingDate: item.filingDate,
filingType: item.filingType,
periodLabel: item.filingType === '10-Q' ? 'Quarter End' : 'Fiscal Year End',
revenue: item.revenue,
netIncome: item.netIncome,
assets: item.totalAssets,
netMargin: ratioPercent(item.netIncome ?? null, item.revenue ?? null)
}));
}, [analysis?.financials]);
const filteredFinancialSeries = useMemo(() => {
return financialSeries.filter((point) => includesFinancialPeriod(point.filingType, financialPeriodFilter));
}, [financialSeries, financialPeriodFilter]);
const periodEndFilings = useMemo(() => {
return (analysis?.filings ?? []).filter((filing) => isFinancialSnapshotForm(filing.filing_type));
}, [analysis?.filings]);
const selectedFinancialScaleLabel = useMemo(() => {
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)';
}, [financialValueScale]);
const resetJournalForm = useCallback(() => {
setEditingJournalId(null);
setJournalForm(EMPTY_JOURNAL_FORM);
}, []);
}, [isPending, isAuthenticated, loadAnalysis, ticker]);
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');
}
};
const quickLinks = useMemo(() => ({
research: `/research?ticker=${encodeURIComponent(activeTicker)}`,
filings: `/filings?ticker=${encodeURIComponent(activeTicker)}`,
financials: `/financials?ticker=${encodeURIComponent(activeTicker)}`,
graphing: buildGraphingHref(activeTicker)
}), [activeTicker]);
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>;
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading overview...</div>;
}
return (
<AppShell
title="Company Analysis"
subtitle="Research a single ticker across pricing, 10-K/10-Q financials, qualitative filings, and generated AI reports."
activeTicker={analysis?.company.ticker ?? ticker}
actions={(
<>
<Link
href={`/search?ticker=${encodeURIComponent(activeTicker.trim().toUpperCase())}`}
className="inline-flex items-center justify-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)]"
title="Company Overview"
subtitle="A summary-first view of price, business context, valuation, recent developments, and key debate points."
activeTicker={activeTicker}
actions={null}
>
<Search className="size-4" />
Ask with RAG
</Link>
<Button
variant="secondary"
onClick={() => {
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" />
Refresh
</Button>
</>
)}
>
<Panel title="Search Company" subtitle="Enter a ticker symbol to load the latest analysis view.">
<form
className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center"
<AnalysisToolbar
tickerInput={tickerInput}
currentTicker={activeTicker}
onTickerInputChange={setTickerInput}
onSubmit={(event) => {
event.preventDefault();
const normalized = tickerInput.trim().toUpperCase();
if (!normalized) {
return;
}
setTicker(normalized);
}}
>
<Input
value={tickerInput}
aria-label="Analysis ticker"
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
placeholder="Ticker (AAPL)"
className="w-full sm:max-w-xs"
onRefresh={() => {
const normalizedTicker = activeTicker.trim().toUpperCase();
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
void loadAnalysis(normalizedTicker);
}}
quickLinks={quickLinks}
onLinkPrefetch={() => prefetchResearchTicker(activeTicker)}
/>
<Button type="submit" className="w-full sm:w-auto">
<Search className="size-4" />
Analyze
</Button>
{analysis ? (
<>
<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>
<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>
</Panel>
{error ? (
<Panel variant="surface">
<Panel>
<p className="text-sm text-[#ffb5b5]">{error}</p>
</Panel>
) : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<Panel title="Company">
<p className="text-xl font-semibold text-[color:var(--terminal-bright)]">{analysis?.company.companyName ?? ticker}</p>
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{analysis?.company.ticker ?? ticker}</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{analysis?.company.sector ?? 'Sector unavailable'}</p>
{analysis?.company.category ? (
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{analysis.company.category}</p>
) : null}
{analysis?.company.tags.length ? (
<div className="mt-2 flex flex-wrap gap-1">
{analysis.company.tags.map((tag) => (
<span
key={tag}
className="rounded border border-[color:var(--line-weak)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
>
{tag}
</span>
))}
</div>
) : null}
</Panel>
<Panel title="Live Price">
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{formatCurrency(analysis?.quote ?? 0)}</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">CIK {analysis?.company.cik ?? 'n/a'}</p>
</Panel>
<Panel title="Position Value">
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{formatCurrency(analysis?.position?.market_value)}</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{analysis?.position ? `${asNumber(analysis.position.shares).toLocaleString()} shares` : 'Not held in portfolio'}</p>
</Panel>
<Panel title="Position P&L">
<p className={`text-3xl font-semibold ${asNumber(analysis?.position?.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}>
{formatCurrency(analysis?.position?.gain_loss)}
</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{formatPercent(analysis?.position?.gain_loss_pct)}</p>
</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-1 gap-3 sm: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)]">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." variant="surface">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading price history...</p>
) : priceSeries.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No price history available.</p>
) : (
<div className="h-[260px] sm:h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={priceSeries}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
<XAxis
dataKey="label"
minTickGap={32}
stroke={CHART_MUTED}
fontSize={12}
axisLine={{ stroke: CHART_MUTED }}
tickLine={{ stroke: CHART_MUTED }}
tick={{ fill: CHART_MUTED }}
{analysis ? (
<>
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
<CompanyOverviewCard
analysis={analysis}
/>
<YAxis
stroke={CHART_MUTED}
fontSize={12}
axisLine={{ stroke: CHART_MUTED }}
tickLine={{ stroke: CHART_MUTED }}
tick={{ fill: CHART_MUTED }}
tickFormatter={(value: number) => `$${value.toFixed(0)}`}
<PriceHistoryCard
loading={loading}
priceHistory={analysis.priceHistory}
quote={analysis.quote}
/>
<Tooltip
formatter={(value) => formatCurrency(Array.isArray(value) ? value[0] : value)}
contentStyle={{
backgroundColor: CHART_TOOLTIP_BG,
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
borderRadius: '0.75rem'
}}
labelStyle={{ color: CHART_TEXT }}
itemStyle={{ color: CHART_TEXT }}
cursor={{ stroke: 'rgba(220, 226, 234, 0.28)', strokeWidth: 1 }}
</section>
<section className="grid gap-6 xl:grid-cols-2">
<CompanyProfileFactsTable analysis={analysis} />
<ValuationFactsTable analysis={analysis} />
</section>
<BullBearPanel
bullBear={analysis.bullBear}
researchHref={quickLinks.research}
onLinkPrefetch={() => prefetchResearchTicker(activeTicker)}
/>
<Line type="monotone" dataKey="close" stroke="#d9dee5" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</Panel>
<Panel
title="Financial Table"
subtitle={`Quarter-end (10-Q) and fiscal-year-end (10-K) snapshots for revenue, net income, assets, and margin. Values shown in ${selectedFinancialScaleLabel}.`}
variant="surface"
actions={(
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap justify-end gap-2">
{FINANCIAL_PERIOD_FILTER_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={option.value === financialPeriodFilter ? 'primary' : 'ghost'}
className="px-2 py-1 text-xs"
onClick={() => setFinancialPeriodFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
<div className="flex flex-wrap justify-end gap-2">
{FINANCIAL_VALUE_SCALE_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={option.value === financialValueScale ? 'primary' : 'ghost'}
className="px-2 py-1 text-xs"
onClick={() => setFinancialValueScale(option.value)}
>
{option.label}
</Button>
))}
</div>
</div>
)}
>
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading financials...</p>
) : filteredFinancialSeries.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No financial rows match the selected period filter.</p>
<RecentDevelopmentsSection recentDevelopments={analysis.recentDevelopments} />
</>
) : (
<div className="space-y-3">
<div className="space-y-3 lg:hidden">
{filteredFinancialSeries.map((point, index) => (
<article key={`${point.filingDate}-${point.filingType}-${index}`} 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-2">
<div>
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{formatLongDate(point.filingDate)}</p>
<p className="text-xs text-[color:var(--terminal-muted)]">{point.filingType} · {point.periodLabel}</p>
</div>
<p className={`text-sm font-medium ${(point.netIncome ?? 0) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}>
{asScaledFinancialCurrency(point.netIncome, financialValueScale)}
</p>
</div>
<dl className="mt-3 grid grid-cols-1 gap-2 text-xs sm:grid-cols-3">
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Revenue</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(point.revenue, financialValueScale)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Assets</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(point.assets, financialValueScale)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Net Margin</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}</dd>
</div>
</dl>
</article>
))}
</div>
<div className="data-table-wrap hidden lg:block">
<table className="data-table min-w-[820px]">
<thead>
<tr>
<th>Filed</th>
<th>Period</th>
<th>Form</th>
<th>Revenue</th>
<th>Net Income</th>
<th>Assets</th>
<th>Net Margin</th>
</tr>
</thead>
<tbody>
{filteredFinancialSeries.map((point, index) => (
<tr key={`${point.filingDate}-${point.filingType}-${index}`}>
<td>{formatLongDate(point.filingDate)}</td>
<td>{point.periodLabel}</td>
<td>{point.filingType}</td>
<td>{asScaledFinancialCurrency(point.revenue, financialValueScale)}</td>
<td className={(point.netIncome ?? 0) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}>
{asScaledFinancialCurrency(point.netIncome, financialValueScale)}
</td>
<td>{asScaledFinancialCurrency(point.assets, financialValueScale)}</td>
<td>{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Panel>
</div>
<Panel
title="Filings"
subtitle={`${periodEndFilings.length} quarter-end and fiscal-year-end SEC records loaded. Values shown in ${selectedFinancialScaleLabel}.`}
variant="surface"
>
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading filings...</p>
) : periodEndFilings.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No 10-K or 10-Q filings available for this ticker.</p>
) : (
<div className="space-y-3">
<div className="space-y-3 lg:hidden">
{periodEndFilings.map((filing) => (
<article key={filing.accession_number} 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-2">
<div>
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{format(new Date(filing.filing_date), 'MMM dd, yyyy')}</p>
<p className="text-xs text-[color:var(--terminal-muted)]">{filing.filing_type} · {filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}</p>
</div>
{filing.filing_url ? (
<a href={filing.filing_url} target="_blank" rel="noreferrer" className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
SEC filing
</a>
) : null}
</div>
<dl className="mt-3 grid grid-cols-1 gap-2 text-xs sm:grid-cols-3">
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Revenue</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Net Income</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}</dd>
</div>
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
<dt className="text-[color:var(--terminal-muted)]">Assets</dt>
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)}</dd>
</div>
</dl>
</article>
))}
</div>
<div className="data-table-wrap hidden lg:block">
<table className="data-table min-w-[860px]">
<thead>
<tr>
<th>Filed</th>
<th>Period</th>
<th>Type</th>
<th>Revenue</th>
<th>Net Income</th>
<th>Assets</th>
<th>Document</th>
</tr>
</thead>
<tbody>
{periodEndFilings.map((filing) => (
<tr key={filing.accession_number}>
<td>{format(new Date(filing.filing_date), 'MMM dd, yyyy')}</td>
<td>{filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}</td>
<td>{filing.filing_type}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}</td>
<td>{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)}</td>
<td>
{filing.filing_url ? (
<a href={filing.filing_url} target="_blank" rel="noreferrer" className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
SEC filing
</a>
) : (
'n/a'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Panel>
<Panel title="AI Reports" subtitle="Generated filing analyses for this company." variant="surface">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading AI reports...</p>
) : !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.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>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{report.filingType} · {format(new Date(report.filingDate), 'MMM dd, yyyy')}
</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{report.provider} / {report.model}</h4>
</div>
<BrainCircuit className="size-4 text-[color:var(--accent)]" />
</div>
<p className="mt-3 line-clamp-6 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{report.summary}</p>
<div className="mt-4 flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<p className="text-xs text-[color:var(--terminal-muted)]">{report.accessionNumber}</p>
<Link
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(report.accessionNumber)}`}
onMouseEnter={() => prefetchReport(analysis.company.ticker, report.accessionNumber)}
onFocus={() => prefetchReport(analysis.company.ticker, report.accessionNumber)}
className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Open summary
</Link>
</div>
</article>
))}
</div>
)}
</Panel>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[0.95fr_1.3fr]">
<Panel
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>
)}
>
<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 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>
</Panel>
<Panel title="Recent Research Feed" subtitle={`Previewing the latest ${Math.min(journalEntries.length, 4)} research entries for ${activeTicker}.`} variant="surface">
{journalLoading ? (
<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 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.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>
<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>
</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 + research workspace
</div>
<p className="text-sm text-[color:var(--terminal-muted)]">No overview is available for the selected ticker.</p>
</Panel>
)}
</AppShell>
);
}

View File

@@ -96,7 +96,7 @@ export default function AnalysisReportPage() {
subtitle={`Detailed filing analysis${resolvedTicker ? ` for ${resolvedTicker}` : ''}.`}
activeTicker={resolvedTicker}
breadcrumbs={[
{ label: 'Analysis', href: analysisHref },
{ label: 'Overview', href: analysisHref },
{ label: 'Reports', href: analysisHref },
{ label: resolvedTicker || 'Summary' }
]}

View File

@@ -926,7 +926,7 @@ function FinancialsPageContent() {
onFocus={() => prefetchResearchTicker(financials.company.ticker)}
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Open analysis
Open overview
</Link>
<Link
href={buildGraphingHref(financials.company.ticker)}

View File

@@ -197,8 +197,8 @@ export default function CommandCenterPage() {
<Panel title="Quick Links" subtitle="Feature modules">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<Link className="border-l-2 border-[color:var(--line-weak)] py-1 pl-4 pr-2 transition hover:border-[color:var(--line-strong)]" href="/analysis">
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Analysis</p>
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Inspect one company across prices, filings, financials, and AI reports.</p>
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Overview</p>
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Inspect one company across price, SEC context, valuation, and recent developments.</p>
</Link>
<Link className="border-l-2 border-[color:var(--line-weak)] py-1 pl-4 pr-2 transition hover:border-[color:var(--line-strong)]" href="/financials">
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Financials</p>

View File

@@ -346,7 +346,7 @@ export default function PortfolioPage() {
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="inline-flex items-center 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)]"
>
Analysis
Overview
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}
@@ -444,7 +444,7 @@ export default function PortfolioPage() {
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Analysis
Overview
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}

View File

@@ -408,7 +408,7 @@ function ResearchPageContent() {
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: 'Overview', href: ticker ? `/analysis?ticker=${encodeURIComponent(ticker)}` : '/analysis' },
{ label: 'Research' }
]}
actions={(
@@ -430,14 +430,14 @@ function ResearchPageContent() {
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
Open overview
</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.">
<Panel title="Select a company" subtitle="Open this page with `?ticker=NVDA` or use the Coverage and Overview 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}

View File

@@ -384,7 +384,7 @@ export default function WatchlistPage() {
onFocus={() => prefetchResearchTicker(item.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)]"
>
Analyze
Open overview
<ArrowRight className="size-3" />
</Link>
<Link
@@ -539,7 +539,7 @@ export default function WatchlistPage() {
onFocus={() => prefetchResearchTicker(item.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)]"
>
Analyze
Open overview
<ArrowRight className="size-3" />
</Link>
<Link

View File

@@ -0,0 +1,69 @@
'use client';
import Link from 'next/link';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
type AnalysisToolbarProps = {
tickerInput: string;
currentTicker: string;
onTickerInputChange: (value: string) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onRefresh: () => void;
quickLinks: {
research: string;
filings: string;
financials: string;
graphing: string;
};
onLinkPrefetch?: () => void;
};
export function AnalysisToolbar(props: AnalysisToolbarProps) {
return (
<form
className="border-t border-[color:var(--line-weak)] pt-4"
onSubmit={props.onSubmit}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<p className="panel-heading text-xs uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">Company overview</p>
<h2 className="text-lg font-semibold text-[color:var(--terminal-bright)]">Inspect the latest high-level picture for {props.currentTicker}</h2>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={props.tickerInput}
aria-label="Overview ticker"
onChange={(event) => props.onTickerInputChange(event.target.value.toUpperCase())}
placeholder="Ticker (AAPL)"
className="w-full sm:min-w-[180px]"
/>
<Button type="submit">
<Search className="size-4" />
Load overview
</Button>
<Button type="button" variant="secondary" onClick={props.onRefresh}>
Refresh
</Button>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-sm">
<Link href={props.quickLinks.research} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="rounded-full border border-[color:var(--line-weak)] px-3 py-1.5 text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]">
Research
</Link>
<Link href={props.quickLinks.filings} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="rounded-full border border-[color:var(--line-weak)] px-3 py-1.5 text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]">
Filings
</Link>
<Link href={props.quickLinks.financials} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="rounded-full border border-[color:var(--line-weak)] px-3 py-1.5 text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]">
Financials
</Link>
<Link href={props.quickLinks.graphing} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="rounded-full border border-[color:var(--line-weak)] px-3 py-1.5 text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-soft)]">
Graphing
</Link>
</div>
</form>
);
}

View File

@@ -0,0 +1,56 @@
import Link from 'next/link';
import { Panel } from '@/components/ui/panel';
import type { CompanyBullBear } from '@/lib/types';
type BullBearPanelProps = {
bullBear: CompanyBullBear;
researchHref: string;
onLinkPrefetch?: () => void;
};
export function BullBearPanel(props: BullBearPanelProps) {
const hasContent = props.bullBear.bull.length > 0 || props.bullBear.bear.length > 0;
return (
<Panel
title="Bull vs Bear"
subtitle="The highest-level reasons investors may lean in or lean out right now."
className="pt-2"
>
{!hasContent ? (
<div className="border-t border-dashed border-[color:var(--line-weak)] py-5 text-sm text-[color:var(--terminal-muted)]">
No synthesis inputs are available yet. Add memo sections or filing context in Research to populate this debate surface.
<div className="mt-4">
<Link href={props.researchHref} onMouseEnter={props.onLinkPrefetch} onFocus={props.onLinkPrefetch} className="text-xs uppercase tracking-[0.14em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
Open research workspace
</Link>
</div>
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<section className="border-t border-[rgba(150,245,191,0.24)] pt-5">
<h3 className="text-lg font-semibold text-[color:var(--terminal-bright)]">Bull case</h3>
<ul className="mt-4 space-y-3">
{props.bullBear.bull.map((item) => (
<li key={item} className="border-t border-[rgba(150,245,191,0.16)] pt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">
{item}
</li>
))}
</ul>
</section>
<section className="border-t border-[rgba(255,159,159,0.24)] pt-5">
<h3 className="text-lg font-semibold text-[color:var(--terminal-bright)]">Bear case</h3>
<ul className="mt-4 space-y-3">
{props.bullBear.bear.map((item) => (
<li key={item} className="border-t border-[rgba(255,159,159,0.16)] pt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">
{item}
</li>
))}
</ul>
</section>
</div>
)}
</Panel>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { ExternalLink } from "lucide-react";
import { Panel } from "@/components/ui/panel";
import type { CompanyAnalysis } from "@/lib/types";
type CompanyOverviewCardProps = {
analysis: CompanyAnalysis;
};
export function CompanyOverviewCard(props: CompanyOverviewCardProps) {
const [expanded, setExpanded] = useState(false);
const description =
props.analysis.companyProfile.description ??
"No annual filing business description is available yet.";
const needsClamp = description.length > 320;
return (
<Panel className="h-full pt-2">
<div className="space-y-5">
<div>
<div>
<h2 className="text-2xl font-semibold text-[color:var(--terminal-bright)]">
{props.analysis.company.companyName}
</h2>
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">
{props.analysis.company.ticker}
</p>
<p className="mt-3 text-sm text-[color:var(--terminal-muted)]">
{props.analysis.company.sector ??
props.analysis.companyProfile.industry ??
"Sector unavailable"}
{props.analysis.company.category
? ` · ${props.analysis.company.category}`
: ""}
{props.analysis.company.cik
? ` · CIK ${props.analysis.company.cik}`
: ""}
</p>
</div>
</div>
<div className="border-t border-[color:var(--line-weak)] py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
Business description
</p>
<p className="mt-3 text-sm leading-7 text-[color:var(--terminal-bright)]">
{expanded || !needsClamp
? description
: `${description.slice(0, 320).trimEnd()}...`}
</p>
</div>
{props.analysis.companyProfile.website ? (
<a
href={props.analysis.companyProfile.website}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.14em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Website
<ExternalLink className="size-3.5" />
</a>
) : null}
</div>
{needsClamp ? (
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="mt-3 text-xs uppercase tracking-[0.14em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
{expanded ? "Show less" : "Read more"}
</button>
) : null}
</div>
</div>
</Panel>
);
}

View File

@@ -0,0 +1,70 @@
import { Fragment } from 'react';
import { Panel } from '@/components/ui/panel';
import { formatScaledNumber } from '@/lib/format';
import type { CompanyAnalysis } from '@/lib/types';
type CompanyProfileFactsTableProps = {
analysis: CompanyAnalysis;
};
function factValue(value: string | null | undefined) {
return value && value.trim().length > 0 ? value : 'n/a';
}
function employeeCountLabel(value: number | null) {
return value === null ? 'n/a' : formatScaledNumber(value, { maximumFractionDigits: 1 });
}
export function CompanyProfileFactsTable(props: CompanyProfileFactsTableProps) {
const items = [
{ label: 'Exchange', value: factValue(props.analysis.companyProfile.exchange) },
{ label: 'Industry', value: factValue(props.analysis.companyProfile.industry ?? props.analysis.company.sector) },
{ label: 'Country / state', value: factValue(props.analysis.companyProfile.country) },
{ label: 'Fiscal year end', value: factValue(props.analysis.companyProfile.fiscalYearEnd) },
{ label: 'Employees', value: employeeCountLabel(props.analysis.companyProfile.employeeCount) },
{ label: 'Website', value: factValue(props.analysis.companyProfile.website) },
{ label: 'Category', value: factValue(props.analysis.company.category) },
{ label: 'CIK', value: factValue(props.analysis.company.cik) }
];
const rows = Array.from({ length: Math.ceil(items.length / 2) }, (_, index) => items.slice(index * 2, index * 2 + 2));
return (
<Panel
title="Company profile facts"
className="pt-2"
>
<div className="overflow-x-auto">
<table className="w-full border-collapse table-fixed">
<tbody>
{rows.map((row) => (
<tr key={row.map((item) => item.label).join('-')} className="border-t border-[color:var(--line-weak)]">
{row.map((item) => (
<Fragment key={item.label}>
<th className="w-[18%] py-2 pr-3 text-left align-top text-[11px] font-medium uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
{item.label}
</th>
<td className="w-[32%] py-2 pr-4 text-sm text-[color:var(--terminal-bright)]">
{item.label === 'Website' && item.value !== 'n/a' ? (
<a href={item.value} target="_blank" rel="noreferrer" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
{item.value}
</a>
) : (
item.value
)}
</td>
</Fragment>
))}
{row.length === 1 ? (
<>
<th className="w-[18%] py-2 pr-3" />
<td className="w-[32%] py-2 pr-4" />
</>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</Panel>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { format } from 'date-fns';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { Panel } from '@/components/ui/panel';
import { formatCurrency } from '@/lib/format';
type PriceHistoryCardProps = {
loading: boolean;
priceHistory: Array<{ date: string; close: number }>;
quote: number;
};
const CHART_TEXT = '#f3f5f7';
const CHART_MUTED = '#a1a9b3';
const CHART_GRID = 'rgba(196, 202, 211, 0.18)';
const CHART_TOOLTIP_BG = 'rgba(31, 34, 39, 0.96)';
const CHART_TOOLTIP_BORDER = 'rgba(220, 226, 234, 0.24)';
function formatShortDate(value: string) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : format(parsed, 'MMM yy');
}
function formatLongDate(value: string) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : format(parsed, 'MMM dd, yyyy');
}
export function PriceHistoryCard(props: PriceHistoryCardProps) {
const series = props.priceHistory.map((point) => ({
...point,
label: formatShortDate(point.date)
}));
const firstPoint = props.priceHistory[0] ?? null;
const lastPoint = props.priceHistory[props.priceHistory.length - 1] ?? null;
const change = firstPoint && lastPoint ? lastPoint.close - firstPoint.close : null;
const changePct = firstPoint && lastPoint && firstPoint.close !== 0
? ((lastPoint.close - firstPoint.close) / firstPoint.close) * 100
: null;
return (
<Panel
title="Price chart"
subtitle="One-year weekly close with current spot price and trailing move."
className="h-full pt-2"
>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_220px]">
<div>
{props.loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading price history...</p>
) : series.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No price history available.</p>
) : (
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={series}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
<XAxis
dataKey="label"
minTickGap={32}
stroke={CHART_MUTED}
fontSize={12}
axisLine={{ stroke: CHART_MUTED }}
tickLine={{ stroke: CHART_MUTED }}
tick={{ fill: CHART_MUTED }}
/>
<YAxis
stroke={CHART_MUTED}
fontSize={12}
axisLine={{ stroke: CHART_MUTED }}
tickLine={{ stroke: CHART_MUTED }}
tick={{ fill: CHART_MUTED }}
tickFormatter={(value: number) => `$${value.toFixed(0)}`}
/>
<Tooltip
formatter={(value) => formatCurrency(Array.isArray(value) ? value[0] : value)}
labelFormatter={(value) => String(value)}
contentStyle={{
backgroundColor: CHART_TOOLTIP_BG,
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
borderRadius: '0.75rem'
}}
labelStyle={{ color: CHART_TEXT }}
itemStyle={{ color: CHART_TEXT }}
cursor={{ stroke: 'rgba(220, 226, 234, 0.28)', strokeWidth: 1 }}
/>
<Line type="monotone" dataKey="close" stroke="#d9dee5" strokeWidth={2.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="grid gap-0 border-t border-[color:var(--line-weak)] sm:grid-cols-3 xl:grid-cols-1 xl:border-l">
<div className="border-b border-[color:var(--line-weak)] px-4 py-4 xl:border-b xl:border-l-0">
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Current price</p>
<p className="mt-2 text-3xl font-semibold text-[color:var(--terminal-bright)]">{formatCurrency(props.quote)}</p>
</div>
<div className="border-b border-[color:var(--line-weak)] px-4 py-4 xl:border-b">
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">1Y change</p>
<p className={`mt-2 text-2xl font-semibold ${change !== null && change < 0 ? 'text-[#ff9f9f]' : 'text-[#96f5bf]'}`}>
{change === null ? 'n/a' : formatCurrency(change)}
</p>
</div>
<div className="px-4 py-4">
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">1Y return</p>
<p className={`mt-2 text-2xl font-semibold ${changePct !== null && changePct < 0 ? 'text-[#ff9f9f]' : 'text-[#96f5bf]'}`}>
{changePct === null ? 'n/a' : `${changePct.toFixed(2)}%`}
</p>
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
{firstPoint && lastPoint ? `${formatLongDate(firstPoint.date)} to ${formatLongDate(lastPoint.date)}` : 'No comparison range'}
</p>
</div>
</div>
</div>
</Panel>
);
}

View File

@@ -0,0 +1,58 @@
import { format } from 'date-fns';
import { ExternalLink } from 'lucide-react';
import { Panel } from '@/components/ui/panel';
import { WeeklySnapshotCard } from '@/components/analysis/weekly-snapshot-card';
import type { RecentDevelopments } from '@/lib/types';
type RecentDevelopmentsSectionProps = {
recentDevelopments: RecentDevelopments;
};
function formatDate(value: string) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : format(parsed, 'MMM dd, yyyy');
}
export function RecentDevelopmentsSection(props: RecentDevelopmentsSectionProps) {
return (
<section className="grid gap-6 xl:grid-cols-[minmax(280px,0.72fr)_minmax(0,1.28fr)]">
<WeeklySnapshotCard snapshot={props.recentDevelopments.weeklySnapshot} />
<Panel title="Recent Developments" subtitle="SEC-first event cards sourced from filings and attached analysis." className="pt-2">
{props.recentDevelopments.items.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No recent development items are available for this ticker yet.</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{props.recentDevelopments.items.map((item) => (
<article key={item.id} className="border-t border-[color:var(--line-weak)] pt-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
{item.kind} · {formatDate(item.publishedAt)}
</p>
<h3 className="mt-2 text-base font-semibold text-[color:var(--terminal-bright)]">{item.title}</h3>
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
{item.source}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{item.summary ?? 'No summary is available for this development item yet.'}</p>
<div className="mt-4 flex items-center justify-between gap-3">
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
{item.accessionNumber ?? 'No accession'}
</p>
{item.url ? (
<a href={item.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.14em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
Open filing
<ExternalLink className="size-3.5" />
</a>
) : null}
</div>
</article>
))}
</div>
)}
</Panel>
</section>
);
}

View File

@@ -0,0 +1,63 @@
import { Fragment } from 'react';
import { Panel } from '@/components/ui/panel';
import { formatCompactCurrency, formatScaledNumber } from '@/lib/format';
import type { CompanyAnalysis } from '@/lib/types';
type ValuationFactsTableProps = {
analysis: CompanyAnalysis;
};
function formatRatio(value: number | null) {
return value === null ? 'n/a' : `${value.toFixed(2)}x`;
}
function formatShares(value: number | null) {
return value === null ? 'n/a' : formatScaledNumber(value, { maximumFractionDigits: 2 });
}
export function ValuationFactsTable(props: ValuationFactsTableProps) {
const items = [
{ label: 'Source', value: props.analysis.valuationSnapshot.source },
{ label: 'Market cap', value: props.analysis.valuationSnapshot.marketCap === null ? 'n/a' : formatCompactCurrency(props.analysis.valuationSnapshot.marketCap) },
{ label: 'Enterprise value', value: props.analysis.valuationSnapshot.enterpriseValue === null ? 'n/a' : formatCompactCurrency(props.analysis.valuationSnapshot.enterpriseValue) },
{ label: 'Shares outstanding', value: formatShares(props.analysis.valuationSnapshot.sharesOutstanding) },
{ label: 'Trailing P/E', value: formatRatio(props.analysis.valuationSnapshot.trailingPe) },
{ label: 'EV / Revenue', value: formatRatio(props.analysis.valuationSnapshot.evToRevenue) },
{ label: 'EV / EBITDA', value: formatRatio(props.analysis.valuationSnapshot.evToEbitda) }
];
const rows = Array.from({ length: Math.ceil(items.length / 2) }, (_, index) => items.slice(index * 2, index * 2 + 2));
return (
<Panel
title="Valuation"
className="pt-2"
>
<div className="overflow-x-auto">
<table className="w-full border-collapse table-fixed">
<tbody>
{rows.map((row) => (
<tr key={row.map((item) => item.label).join('-')} className="border-t border-[color:var(--line-weak)]">
{row.map((item) => (
<Fragment key={item.label}>
<th className="w-[18%] py-2 pr-3 text-left align-top text-[11px] font-medium uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
{item.label}
</th>
<td className="w-[32%] py-2 pr-4 text-sm text-[color:var(--terminal-bright)]">
{item.value}
</td>
</Fragment>
))}
{row.length === 1 ? (
<>
<th className="w-[18%] py-2 pr-3" />
<td className="w-[32%] py-2 pr-4" />
</>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</Panel>
);
}

View File

@@ -0,0 +1,36 @@
import type { CompanyValuationSnapshot } from '@/lib/types';
import { formatCompactCurrency, formatScaledNumber } from '@/lib/format';
type ValuationStatGridProps = {
valuation: CompanyValuationSnapshot;
};
function formatRatio(value: number | null) {
return value === null ? 'n/a' : `${value.toFixed(2)}x`;
}
function formatShares(value: number | null) {
return value === null ? 'n/a' : formatScaledNumber(value, { maximumFractionDigits: 2 });
}
export function ValuationStatGrid(props: ValuationStatGridProps) {
const items = [
{ label: 'Market cap', value: props.valuation.marketCap === null ? 'n/a' : formatCompactCurrency(props.valuation.marketCap) },
{ label: 'Enterprise value', value: props.valuation.enterpriseValue === null ? 'n/a' : formatCompactCurrency(props.valuation.enterpriseValue) },
{ label: 'Shares out.', value: formatShares(props.valuation.sharesOutstanding) },
{ label: 'Trailing P/E', value: formatRatio(props.valuation.trailingPe) },
{ label: 'EV / Revenue', value: formatRatio(props.valuation.evToRevenue) },
{ label: 'EV / EBITDA', value: formatRatio(props.valuation.evToEbitda) }
];
return (
<div className="grid gap-3 sm:grid-cols-2">
{items.map((item) => (
<div key={item.label} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.label}</p>
<p className="mt-2 text-lg font-semibold text-[color:var(--terminal-bright)]">{item.value}</p>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Panel } from '@/components/ui/panel';
import type { RecentDevelopmentsWeeklySnapshot } from '@/lib/types';
type WeeklySnapshotCardProps = {
snapshot: RecentDevelopmentsWeeklySnapshot | null;
};
export function WeeklySnapshotCard(props: WeeklySnapshotCardProps) {
return (
<Panel title="Past 7 Days" subtitle="A compact narrative of the most recent filing-driven developments." className="h-full pt-2">
{props.snapshot ? (
<div className="space-y-4">
<div className="border-t border-[color:var(--line-weak)] py-4">
<p className="text-sm leading-7 text-[color:var(--terminal-bright)]">{props.snapshot.summary}</p>
</div>
{props.snapshot.highlights.length > 0 ? (
<ul className="space-y-3">
{props.snapshot.highlights.map((highlight) => (
<li key={highlight} className="border-t border-[color:var(--line-weak)] pt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">
{highlight}
</li>
))}
</ul>
) : null}
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
<span>{props.snapshot.itemCount} tracked items</span>
<span>{props.snapshot.startDate} to {props.snapshot.endDate}</span>
</div>
</div>
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">No weekly snapshot is available yet.</p>
)}
</Panel>
);
}

View File

@@ -67,7 +67,7 @@ const NAV_ITEMS: NavConfigItem[] = [
{
id: "analysis",
href: "/analysis",
label: "Analysis",
label: "Overview",
icon: LineChart,
group: "research",
matchMode: "prefix",
@@ -200,41 +200,41 @@ function buildDefaultBreadcrumbs(
if (pathname.startsWith("/analysis/reports/")) {
return [
{ label: "Analysis", href: analysisHref },
{ label: "Overview", href: analysisHref },
{ label: "Reports", href: analysisHref },
{ label: activeTicker ?? "Summary" },
];
}
if (pathname.startsWith("/analysis")) {
return [{ label: "Analysis" }];
return [{ label: "Overview" }];
}
if (pathname.startsWith("/research")) {
return [
{ label: "Analysis", href: analysisHref },
{ label: "Overview", href: analysisHref },
{ label: "Research", href: researchHref },
];
}
if (pathname.startsWith("/financials")) {
return [{ label: "Analysis", href: analysisHref }, { label: "Financials" }];
return [{ label: "Overview", href: analysisHref }, { label: "Financials" }];
}
if (pathname.startsWith("/graphing")) {
return [
{ label: "Analysis", href: analysisHref },
{ label: "Overview", href: analysisHref },
{ label: "Graphing", href: graphingHref },
{ label: activeTicker ?? "Compare Set" },
];
}
if (pathname.startsWith("/filings")) {
return [{ label: "Analysis", href: analysisHref }, { label: "Filings" }];
return [{ label: "Overview", href: analysisHref }, { label: "Filings" }];
}
if (pathname.startsWith("/search")) {
return [{ label: "Analysis", href: analysisHref }, { label: "Search" }];
return [{ label: "Overview", href: analysisHref }, { label: "Search" }];
}
if (pathname.startsWith("/portfolio")) {

View File

@@ -182,11 +182,13 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
await page.getByLabel('NVDA priority').selectOption('high');
await expect(page.getByLabel('NVDA priority')).toHaveValue('high');
await page.getByRole('link', { name: /^Analyze/ }).first().click();
await page.getByRole('link', { name: /^Open overview/ }).first().click();
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
await expect(page.getByText('Coverage Workflow')).toBeVisible();
await expect(page.getByText('Bull vs Bear')).toBeVisible();
await expect(page.getByText('Past 7 Days')).toBeVisible();
await expect(page.getByText('Recent Developments')).toBeVisible();
await page.getByRole('link', { name: 'Open research' }).click();
await page.getByRole('link', { name: 'Research' }).first().click();
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
await page.getByLabel('Research note title').fill('Own-the-stack moat check');
await page.getByLabel('Research note summary').fill('Initial moat checkpoint');
@@ -202,8 +204,8 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
await page.locator('button', { hasText: 'Upload file' }).click();
await expect(page.getByText('Uploaded research file.')).toBeVisible();
await page.goto(`/analysis?ticker=NVDA`);
await page.getByRole('link', { name: 'Open summary' }).first().click();
await page.goto(`/filings?ticker=NVDA`);
await page.getByRole('link', { name: 'Summary' }).first().click();
await expect(page).toHaveURL(/\/analysis\/reports\/NVDA\//);
await page.getByRole('button', { name: 'Save to library' }).click();
await expect(page.getByText('Saved to the company research library.')).toBeVisible();
@@ -229,6 +231,9 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
await page.goto('/analysis?ticker=NVDA');
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
await expect(page.getByText('Bull vs Bear')).toBeVisible();
await expect(page.getByText('Past 7 Days')).toBeVisible();
await expect(page.getByText('Recent Developments')).toBeVisible();
await page.goto('/financials?ticker=NVDA');
await expect(page).toHaveURL(/\/financials\?ticker=NVDA/);

View File

@@ -70,6 +70,10 @@ import {
upsertWatchlistItemRecord
} from '@/lib/server/repos/watchlist';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
import { getRecentDevelopments } from '@/lib/server/recent-developments';
import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile';
import { getCompanyDescription } from '@/lib/server/sec-description';
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
import {
enqueueTask,
@@ -1362,13 +1366,15 @@ export const app = new Elysia({ prefix: '/api' })
return jsonError('ticker is required');
}
const [filings, holding, watchlistItem, liveQuote, priceHistory, journalPreview] = await Promise.all([
const [filings, holding, watchlistItem, liveQuote, priceHistory, journalPreview, memo, secProfile] = await Promise.all([
listFilingsRecords({ ticker, limit: 40 }),
getHoldingByTicker(session.user.id, ticker),
getWatchlistItemByTicker(session.user.id, ticker),
getQuote(ticker),
getPriceHistory(ticker),
listResearchJournalEntries(session.user.id, ticker, 6)
listResearchJournalEntries(session.user.id, ticker, 6),
getResearchMemoByTicker(session.user.id, ticker),
getSecCompanyProfile(ticker)
]);
const redactedFilings = filings
.map(redactInternalFilingAnalysisFields)
@@ -1376,6 +1382,7 @@ export const app = new Elysia({ prefix: '/api' })
const latestFiling = redactedFilings[0] ?? null;
const companyName = latestFiling?.company_name
?? secProfile?.companyName
?? holding?.company_name
?? watchlistItem?.company_name
?? ticker;
@@ -1416,6 +1423,11 @@ export const app = new Elysia({ prefix: '/api' })
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
: null
};
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
const [description, synthesizedDevelopments] = await Promise.all([
getCompanyDescription(annualFiling),
getRecentDevelopments(ticker, { filings: redactedFilings })
]);
const latestFilingSummary = latestFiling
? {
accessionNumber: latestFiling.accession_number,
@@ -1427,6 +1439,31 @@ export const app = new Elysia({ prefix: '/api' })
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
}
: null;
const companyProfile = toCompanyProfile(secProfile, description);
const valuationSnapshot = deriveValuationSnapshot({
quote: liveQuote,
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
revenue: keyMetrics.revenue,
cash: keyMetrics.cash,
debt: keyMetrics.debt,
netIncome: keyMetrics.netIncome
});
const synthesis = await synthesizeCompanyOverview({
ticker,
companyName,
description,
memo,
latestFilingSummary,
recentAiReports: aiReports.slice(0, 5),
recentDevelopments: synthesizedDevelopments.items
});
const recentDevelopments = {
...synthesizedDevelopments,
weeklySnapshot: synthesis.weeklySnapshot,
status: synthesizedDevelopments.items.length > 0
? synthesis.weeklySnapshot ? 'ready' : 'partial'
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
} as const;
return Response.json({
analysis: {
@@ -1453,7 +1490,11 @@ export const app = new Elysia({ prefix: '/api' })
journalPreview,
recentAiReports: aiReports.slice(0, 5),
latestFilingSummary,
keyMetrics
keyMetrics,
companyProfile,
valuationSnapshot,
bullBear: synthesis.bullBear,
recentDevelopments
}
});
}, {

View File

@@ -485,6 +485,14 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
latestFilingSummary: { accessionNumber: string; summary: string | null } | null;
keyMetrics: { revenue: number | null; netMargin: number | null };
position: { company_name: string | null } | null;
companyProfile: { source: string; description: string | null };
valuationSnapshot: { source: string; marketCap: number | null; evToRevenue: number | null };
bullBear: { source: string; bull: string[]; bear: string[] };
recentDevelopments: {
status: string;
items: Array<{ kind: string; accessionNumber: string | null }>;
weeklySnapshot: { source: string; itemCount: number } | null;
};
};
}).analysis;
@@ -499,6 +507,12 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
expect(payload.keyMetrics.revenue).toBe(41000000000);
expect(payload.keyMetrics.netMargin).not.toBeNull();
expect(payload.position?.company_name).toBe('Netflix, Inc.');
expect(['sec_derived', 'unavailable']).toContain(payload.companyProfile.source);
expect(['derived', 'partial', 'unavailable']).toContain(payload.valuationSnapshot.source);
expect(['ai_synthesized', 'memo_fallback', 'unavailable']).toContain(payload.bullBear.source);
expect(['ready', 'partial', 'unavailable']).toContain(payload.recentDevelopments.status);
expect(payload.recentDevelopments.items[0]?.accessionNumber).toBe('0000000000-26-000777');
expect(payload.recentDevelopments.weeklySnapshot?.itemCount ?? 0).toBeGreaterThanOrEqual(1);
const updatedEntry = await jsonRequest('PATCH', `/api/research/journal/${entryId}`, {
title: 'Thesis refresh v2',

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, mock } from 'bun:test';
import { __companyOverviewSynthesisInternals, synthesizeCompanyOverview } from './company-overview-synthesis';
describe('company overview synthesis', () => {
it('parses strict json AI responses', () => {
const parsed = __companyOverviewSynthesisInternals.parseAiJson(JSON.stringify({
bull: ['Demand remains durable'],
bear: ['Valuation is demanding'],
weeklySummary: 'The week centered on enterprise demand and new disclosures.',
weeklyHighlights: ['8-K signaled a new contract']
}));
expect(parsed.bull).toEqual(['Demand remains durable']);
expect(parsed.bear).toEqual(['Valuation is demanding']);
expect(parsed.weeklyHighlights).toEqual(['8-K signaled a new contract']);
});
it('falls back to memo-backed bullets when AI is unavailable', async () => {
const result = await synthesizeCompanyOverview({
ticker: 'MSFT',
companyName: 'Microsoft Corp',
description: 'Microsoft builds software and cloud infrastructure.',
memo: {
id: 1,
user_id: 'u1',
organization_id: null,
ticker: 'MSFT',
rating: 'buy',
conviction: 'high',
time_horizon_months: 24,
packet_title: null,
packet_subtitle: null,
thesis_markdown: 'Azure demand remains durable.',
variant_view_markdown: '',
catalysts_markdown: 'Copilot monetization can expand ARPU.',
risks_markdown: 'Competition may compress pricing.',
disconfirming_evidence_markdown: 'Enterprise optimization could slow seat growth.',
next_actions_markdown: '',
created_at: '2026-03-01T00:00:00.000Z',
updated_at: '2026-03-10T00:00:00.000Z'
},
latestFilingSummary: null,
recentAiReports: [],
recentDevelopments: []
}, {
aiConfigured: false
});
expect(result.bullBear.source).toBe('memo_fallback');
expect(result.bullBear.bull[0]).toContain('Azure demand');
expect(result.bullBear.bear[0]).toContain('Competition');
expect(result.weeklySnapshot?.source).toBe('heuristic');
});
it('uses AI output when available', async () => {
const runAnalysis = mock(async () => ({
provider: 'zhipu' as const,
model: 'glm-5',
text: JSON.stringify({
bull: ['Demand remains durable'],
bear: ['Spending could normalize'],
weeklySummary: 'The week was defined by a new contract disclosure.',
weeklyHighlights: ['8-K disclosed a new enterprise customer']
})
}));
const result = await synthesizeCompanyOverview({
ticker: 'MSFT',
companyName: 'Microsoft Corp',
description: 'Microsoft builds software and cloud infrastructure.',
memo: null,
latestFilingSummary: null,
recentAiReports: [],
recentDevelopments: []
}, {
aiConfigured: true,
runAnalysis
});
expect(runAnalysis).toHaveBeenCalledTimes(1);
expect(result.bullBear.source).toBe('ai_synthesized');
expect(result.bullBear.bull).toEqual(['Demand remains durable']);
expect(result.weeklySnapshot?.source).toBe('ai_synthesized');
});
});

View File

@@ -0,0 +1,273 @@
import { isAiConfigured, runAiAnalysis } from '@/lib/server/ai';
import type {
CompanyAiReport,
CompanyBullBear,
RecentDevelopmentItem,
RecentDevelopmentsWeeklySnapshot,
ResearchMemo
} from '@/lib/types';
type SynthesisResult = {
bullBear: CompanyBullBear;
weeklySnapshot: RecentDevelopmentsWeeklySnapshot | null;
};
type SynthesisOptions = {
now?: Date;
runAnalysis?: typeof runAiAnalysis;
aiConfigured?: boolean;
};
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const SYNTHESIS_CACHE_TTL_MS = 1000 * 60 * 30;
const synthesisCache = new Map<string, CacheEntry<SynthesisResult>>();
function normalizeLine(line: string) {
return line
.replace(/^[-*+]\s+/, '')
.replace(/^\d+\.\s+/, '')
.replace(/[`#>*_~]/g, ' ')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function bulletizeText(value: string | null | undefined, fallback: string[] = []) {
if (!value) {
return fallback;
}
const lines = value
.split(/\n+/)
.map(normalizeLine)
.filter((line) => line.length >= 18);
if (lines.length > 0) {
return lines;
}
return value
.split(/(?<=[.!?])\s+/)
.map(normalizeLine)
.filter((sentence) => sentence.length >= 18);
}
function dedupe(items: string[], limit = 5) {
const seen = new Set<string>();
const result: string[] = [];
for (const item of items) {
const normalized = item.trim();
if (!normalized) {
continue;
}
const key = normalized.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(normalized);
if (result.length >= limit) {
break;
}
}
return result;
}
function fallbackBullBear(input: {
memo: ResearchMemo | null;
latestFilingSummary: { summary: string | null } | null;
recentDevelopments: RecentDevelopmentItem[];
}) {
const bull = dedupe([
...bulletizeText(input.memo?.thesis_markdown),
...bulletizeText(input.memo?.catalysts_markdown),
...bulletizeText(input.latestFilingSummary?.summary)
]);
const bear = dedupe([
...bulletizeText(input.memo?.risks_markdown),
...bulletizeText(input.memo?.disconfirming_evidence_markdown),
...bulletizeText(input.memo?.variant_view_markdown)
]);
const highlights = dedupe(
input.recentDevelopments
.slice(0, 3)
.map((item) => item.summary ?? item.title),
3
);
const summary = highlights.length > 0
? `Recent developments centered on ${highlights.join(' ')}`
: 'No recent SEC development summaries are available yet.';
return {
bullBear: {
source: bull.length > 0 || bear.length > 0 ? 'memo_fallback' : 'unavailable',
bull,
bear,
updatedAt: new Date().toISOString()
} satisfies CompanyBullBear,
weeklySummary: {
summary,
highlights
}
};
}
function parseAiJson(text: string) {
const fenced = text.match(/```json\s*([\s\S]*?)```/i);
const raw = fenced?.[1] ?? text;
const parsed = JSON.parse(raw) as {
bull?: unknown;
bear?: unknown;
weeklySummary?: unknown;
weeklyHighlights?: unknown;
};
const asItems = (value: unknown) => Array.isArray(value)
? dedupe(value.filter((item): item is string => typeof item === 'string'), 5)
: [];
return {
bull: asItems(parsed.bull),
bear: asItems(parsed.bear),
weeklySummary: typeof parsed.weeklySummary === 'string' ? parsed.weeklySummary.trim() : '',
weeklyHighlights: asItems(parsed.weeklyHighlights).slice(0, 3)
};
}
function buildPrompt(input: {
ticker: string;
companyName: string;
description: string | null;
memo: ResearchMemo | null;
latestFilingSummary: { summary: string | null; filingType: string; filingDate: string } | null;
recentAiReports: CompanyAiReport[];
recentDevelopments: RecentDevelopmentItem[];
}) {
return [
'You are synthesizing a public-equity company overview.',
'Return strict JSON with keys: bull, bear, weeklySummary, weeklyHighlights.',
'bull and bear must each be arrays of 3 to 5 concise strings.',
'weeklyHighlights must be an array of up to 3 concise strings.',
'Do not include markdown, prose before JSON, or code fences unless absolutely necessary.',
'',
`Ticker: ${input.ticker}`,
`Company: ${input.companyName}`,
`Business description: ${input.description ?? 'n/a'}`,
`Memo thesis: ${input.memo?.thesis_markdown ?? 'n/a'}`,
`Memo catalysts: ${input.memo?.catalysts_markdown ?? 'n/a'}`,
`Memo risks: ${input.memo?.risks_markdown ?? 'n/a'}`,
`Memo disconfirming evidence: ${input.memo?.disconfirming_evidence_markdown ?? 'n/a'}`,
`Memo variant view: ${input.memo?.variant_view_markdown ?? 'n/a'}`,
`Latest filing summary: ${input.latestFilingSummary?.summary ?? 'n/a'}`,
`Recent AI report summaries: ${input.recentAiReports.map((report) => `${report.filingType} ${report.filingDate}: ${report.summary}`).join(' | ') || 'n/a'}`,
`Recent developments: ${input.recentDevelopments.map((item) => `${item.kind} ${item.publishedAt}: ${item.summary ?? item.title}`).join(' | ') || 'n/a'}`
].join('\n');
}
export async function synthesizeCompanyOverview(
input: {
ticker: string;
companyName: string;
description: string | null;
memo: ResearchMemo | null;
latestFilingSummary: {
accessionNumber: string;
filingDate: string;
filingType: string;
summary: string | null;
} | null;
recentAiReports: CompanyAiReport[];
recentDevelopments: RecentDevelopmentItem[];
},
options?: SynthesisOptions
): Promise<SynthesisResult> {
const now = options?.now ?? new Date();
const cacheKey = JSON.stringify({
ticker: input.ticker,
description: input.description,
memoUpdatedAt: input.memo?.updated_at ?? null,
latestFilingSummary: input.latestFilingSummary?.accessionNumber ?? null,
recentAiReports: input.recentAiReports.map((report) => report.accessionNumber),
recentDevelopments: input.recentDevelopments.map((item) => item.id)
});
const cached = synthesisCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const fallback = fallbackBullBear(input);
const buildWeeklySnapshot = (source: 'ai_synthesized' | 'heuristic', summary: string, highlights: string[]) => ({
summary,
highlights,
itemCount: input.recentDevelopments.length,
startDate: new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
endDate: now.toISOString().slice(0, 10),
updatedAt: now.toISOString(),
source
} satisfies RecentDevelopmentsWeeklySnapshot);
const aiEnabled = options?.aiConfigured ?? isAiConfigured();
if (!aiEnabled) {
const result = {
bullBear: fallback.bullBear,
weeklySnapshot: buildWeeklySnapshot('heuristic', fallback.weeklySummary.summary, fallback.weeklySummary.highlights)
};
synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS });
return result;
}
try {
const runAnalysis = options?.runAnalysis ?? runAiAnalysis;
const aiResult = await runAnalysis(
buildPrompt(input),
'Respond with strict JSON only.',
{ workload: 'report' }
);
const parsed = parseAiJson(aiResult.text);
const bull = parsed.bull.length > 0 ? parsed.bull : fallback.bullBear.bull;
const bear = parsed.bear.length > 0 ? parsed.bear : fallback.bullBear.bear;
const summary = parsed.weeklySummary || fallback.weeklySummary.summary;
const highlights = parsed.weeklyHighlights.length > 0 ? parsed.weeklyHighlights : fallback.weeklySummary.highlights;
const result = {
bullBear: {
source: bull.length > 0 || bear.length > 0 ? 'ai_synthesized' : fallback.bullBear.source,
bull,
bear,
updatedAt: now.toISOString()
} satisfies CompanyBullBear,
weeklySnapshot: buildWeeklySnapshot(
summary || highlights.length > 0 ? 'ai_synthesized' : 'heuristic',
summary || fallback.weeklySummary.summary,
highlights
)
};
synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS });
return result;
} catch {
const result = {
bullBear: fallback.bullBear,
weeklySnapshot: buildWeeklySnapshot('heuristic', fallback.weeklySummary.summary, fallback.weeklySummary.highlights)
};
synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS });
return result;
}
}
export const __companyOverviewSynthesisInternals = {
bulletizeText,
dedupe,
fallbackBullBear,
parseAiJson
};

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'bun:test';
import type { Filing } from '@/lib/types';
import { __recentDevelopmentsInternals, getRecentDevelopments, secFilingsDevelopmentSource } from './recent-developments';
function filing(input: Partial<Filing> & Pick<Filing, 'accession_number' | 'filing_type' | 'filing_date' | 'ticker' | 'cik' | 'company_name'>): Filing {
return {
id: 1,
filing_url: 'https://www.sec.gov/Archives/example.htm',
submission_url: null,
primary_document: 'example.htm',
metrics: null,
analysis: null,
created_at: '2026-03-01T00:00:00.000Z',
updated_at: '2026-03-01T00:00:00.000Z',
...input
};
}
describe('recent developments', () => {
it('prioritizes 8-K items ahead of periodic filings', async () => {
const items = await secFilingsDevelopmentSource.fetch('MSFT', {
now: new Date('2026-03-12T12:00:00.000Z'),
filings: [
filing({
accession_number: '0001',
filing_type: '10-Q',
filing_date: '2026-03-09',
ticker: 'MSFT',
cik: '0000789019',
company_name: 'Microsoft Corp'
}),
filing({
accession_number: '0002',
filing_type: '8-K',
filing_date: '2026-03-10',
ticker: 'MSFT',
cik: '0000789019',
company_name: 'Microsoft Corp'
})
]
});
expect(items[0]?.kind).toBe('8-K');
expect(items[0]?.title).toContain('8-K');
});
it('builds a ready recent developments payload from filing records', async () => {
const developments = await getRecentDevelopments('MSFT', {
now: new Date('2026-03-12T12:00:00.000Z'),
filings: [
filing({
accession_number: '0002',
filing_type: '8-K',
filing_date: '2026-03-10',
ticker: 'MSFT',
cik: '0000789019',
company_name: 'Microsoft Corp',
analysis: {
text: 'The company announced a new enterprise AI contract.',
provider: 'zhipu',
model: 'glm-5'
}
})
]
});
expect(developments.status).toBe('ready');
expect(developments.items).toHaveLength(1);
expect(developments.items[0]?.summary).toContain('enterprise AI contract');
});
it('creates heuristic summaries when filing analysis is unavailable', () => {
const summary = __recentDevelopmentsInternals.buildSummary(
filing({
accession_number: '0003',
filing_type: '10-K',
filing_date: '2026-03-02',
ticker: 'MSFT',
cik: '0000789019',
company_name: 'Microsoft Corp'
})
);
expect(summary).toContain('10-K');
});
});

View File

@@ -0,0 +1,161 @@
import { format } from 'date-fns';
import type { Filing, RecentDevelopmentItem, RecentDevelopments } from '@/lib/types';
export type RecentDevelopmentSourceContext = {
filings: Filing[];
now?: Date;
};
export type RecentDevelopmentSource = {
name: string;
fetch: (ticker: string, context: RecentDevelopmentSourceContext) => Promise<RecentDevelopmentItem[]>;
};
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const RECENT_DEVELOPMENTS_CACHE_TTL_MS = 1000 * 60 * 10;
const recentDevelopmentsCache = new Map<string, CacheEntry<RecentDevelopments>>();
function filingPriority(filing: Filing) {
switch (filing.filing_type) {
case '8-K':
return 0;
case '10-Q':
return 1;
case '10-K':
return 2;
default:
return 3;
}
}
function sortFilings(filings: Filing[]) {
return [...filings].sort((left, right) => {
const dateDelta = Date.parse(right.filing_date) - Date.parse(left.filing_date);
if (dateDelta !== 0) {
return dateDelta;
}
return filingPriority(left) - filingPriority(right);
});
}
function buildTitle(filing: Filing) {
switch (filing.filing_type) {
case '8-K':
return `${filing.company_name} filed an 8-K`;
case '10-K':
return `${filing.company_name} annual filing`;
case '10-Q':
return `${filing.company_name} quarterly filing`;
default:
return `${filing.company_name} filing update`;
}
}
function buildSummary(filing: Filing) {
const analysisSummary = filing.analysis?.text ?? filing.analysis?.legacyInsights ?? null;
if (analysisSummary) {
return analysisSummary;
}
const formattedDate = format(new Date(filing.filing_date), 'MMM dd, yyyy');
if (filing.filing_type === '8-K') {
return `The company disclosed a current report on ${formattedDate}. Review the filing for event-specific detail and attached exhibits.`;
}
return `The company published a ${filing.filing_type} on ${formattedDate}. Review the filing for the latest reported business and financial changes.`;
}
export const secFilingsDevelopmentSource: RecentDevelopmentSource = {
name: 'SEC filings',
async fetch(_ticker, context) {
const now = context.now ?? new Date();
const nowEpoch = now.getTime();
const recentFilings = sortFilings(context.filings)
.filter((filing) => {
const filedAt = Date.parse(filing.filing_date);
if (!Number.isFinite(filedAt)) {
return false;
}
const ageInDays = (nowEpoch - filedAt) / (1000 * 60 * 60 * 24);
if (ageInDays > 14) {
return false;
}
return filing.filing_type === '8-K' || filing.filing_type === '10-K' || filing.filing_type === '10-Q';
})
.slice(0, 8);
return recentFilings.map((filing) => ({
id: `${filing.ticker}-${filing.accession_number}`,
kind: filing.filing_type,
title: buildTitle(filing),
url: filing.filing_url,
source: 'SEC filings',
publishedAt: filing.filing_date,
summary: buildSummary(filing),
accessionNumber: filing.accession_number
}));
}
};
export const yahooDevelopmentSource: RecentDevelopmentSource | null = null;
export const investorRelationsRssSource: RecentDevelopmentSource | null = null;
export async function getRecentDevelopments(
ticker: string,
context: RecentDevelopmentSourceContext,
options?: {
sources?: RecentDevelopmentSource[];
limit?: number;
}
): Promise<RecentDevelopments> {
const normalizedTicker = ticker.trim().toUpperCase();
const limit = options?.limit ?? 6;
const cacheKey = `${normalizedTicker}:${context.filings.map((filing) => filing.accession_number).join(',')}`;
const cached = recentDevelopmentsCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const sources = options?.sources ?? [secFilingsDevelopmentSource];
const itemCollections = await Promise.all(
sources.map(async (source) => {
try {
return await source.fetch(normalizedTicker, context);
} catch {
return [] satisfies RecentDevelopmentItem[];
}
})
);
const items = itemCollections
.flat()
.sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt))
.slice(0, limit);
const result: RecentDevelopments = {
status: items.length > 0 ? 'ready' : 'unavailable',
items,
weeklySnapshot: null
};
recentDevelopmentsCache.set(cacheKey, {
value: result,
expiresAt: Date.now() + RECENT_DEVELOPMENTS_CACHE_TTL_MS
});
return result;
}
export const __recentDevelopmentsInternals = {
buildSummary,
buildTitle,
sortFilings
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'bun:test';
import { __secCompanyProfileInternals, deriveValuationSnapshot } from './sec-company-profile';
describe('sec company profile helpers', () => {
it('formats fiscal year end values', () => {
expect(__secCompanyProfileInternals.formatFiscalYearEnd('0630')).toBe('06/30');
expect(__secCompanyProfileInternals.formatFiscalYearEnd('1231')).toBe('12/31');
expect(__secCompanyProfileInternals.formatFiscalYearEnd('')).toBeNull();
});
it('picks the latest numeric fact across supported namespaces', () => {
const payload = {
facts: {
dei: {
EntityCommonStockSharesOutstanding: {
units: {
shares: [
{ val: 7_400_000_000, filed: '2025-10-31' },
{ val: 7_500_000_000, filed: '2026-01-31' }
]
}
}
}
}
};
expect(
__secCompanyProfileInternals.pickLatestNumericFact(
payload,
['dei'],
['EntityCommonStockSharesOutstanding']
)
).toBe(7_500_000_000);
});
it('derives valuation metrics from free inputs only', () => {
const snapshot = deriveValuationSnapshot({
quote: 200,
sharesOutstanding: 1_000_000,
revenue: 50_000_000,
cash: 5_000_000,
debt: 15_000_000,
netIncome: 10_000_000
});
expect(snapshot.marketCap).toBe(200_000_000);
expect(snapshot.enterpriseValue).toBe(210_000_000);
expect(snapshot.evToRevenue).toBe(4.2);
expect(snapshot.trailingPe).toBe(20);
expect(snapshot.source).toBe('derived');
});
it('marks valuation as unavailable when core inputs are missing', () => {
const snapshot = deriveValuationSnapshot({
quote: null,
sharesOutstanding: null,
revenue: null,
cash: null,
debt: null,
netIncome: null
});
expect(snapshot.marketCap).toBeNull();
expect(snapshot.enterpriseValue).toBeNull();
expect(snapshot.source).toBe('unavailable');
});
});

View File

@@ -0,0 +1,380 @@
import type { CompanyProfile, CompanyValuationSnapshot } from '@/lib/types';
type FetchImpl = typeof fetch;
type SubmissionPayload = {
cik?: string;
name?: string;
tickers?: string[];
exchanges?: string[];
sicDescription?: string;
fiscalYearEnd?: string;
website?: string;
addresses?: {
business?: {
country?: string | null;
countryCode?: string | null;
stateOrCountryDescription?: string | null;
};
};
};
type CompanyFactsPayload = {
facts?: Record<string, Record<string, { units?: Record<string, FactPoint[]> }>>;
};
type FactPoint = {
val?: number;
filed?: string;
end?: string;
};
type ExchangeDirectoryPayload = {
fields?: string[];
data?: Array<Array<string | number | null>>;
};
type ExchangeDirectoryRecord = {
cik: string;
name: string;
ticker: string;
exchange: string | null;
};
export type SecCompanyProfileResult = {
ticker: string;
cik: string | null;
companyName: string | null;
exchange: string | null;
industry: string | null;
country: string | null;
website: string | null;
fiscalYearEnd: string | null;
employeeCount: number | null;
sharesOutstanding: number | null;
};
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const EXCHANGE_DIRECTORY_URL = 'https://www.sec.gov/files/company_tickers_exchange.json';
const SEC_SUBMISSIONS_BASE = 'https://data.sec.gov/submissions';
const SEC_COMPANY_FACTS_BASE = 'https://data.sec.gov/api/xbrl/companyfacts';
const EXCHANGE_CACHE_TTL_MS = 1000 * 60 * 30;
const SUBMISSIONS_CACHE_TTL_MS = 1000 * 60 * 30;
const COMPANY_FACTS_CACHE_TTL_MS = 1000 * 60 * 30;
let exchangeDirectoryCache: CacheEntry<Map<string, ExchangeDirectoryRecord>> | null = null;
const submissionsCache = new Map<string, CacheEntry<SubmissionPayload>>();
const companyFactsCache = new Map<string, CacheEntry<CompanyFactsPayload>>();
function envUserAgent() {
return process.env.SEC_USER_AGENT || 'Fiscal Clone <support@fiscal.local>';
}
async function fetchJson<T>(url: string, fetchImpl: FetchImpl = fetch): Promise<T> {
const response = await fetchImpl(url, {
headers: {
'User-Agent': envUserAgent(),
Accept: 'application/json'
},
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`SEC request failed (${response.status})`);
}
return await response.json() as T;
}
function asNormalizedString(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function normalizeCik(value: string | number | null | undefined) {
const digits = String(value ?? '').replace(/\D/g, '');
return digits.length > 0 ? digits : null;
}
function toPaddedCik(value: string | null) {
return value ? value.padStart(10, '0') : null;
}
function formatFiscalYearEnd(value: string | null | undefined) {
const normalized = asNormalizedString(value);
if (!normalized) {
return null;
}
const digits = normalized.replace(/\D/g, '');
if (digits.length !== 4) {
return normalized;
}
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
function pointDate(point: FactPoint) {
return Date.parse(point.filed ?? point.end ?? '');
}
function pickLatestNumericFact(payload: CompanyFactsPayload, namespaces: string[], tags: string[]) {
const points: FactPoint[] = [];
for (const namespace of namespaces) {
const facts = payload.facts?.[namespace] ?? {};
for (const tag of tags) {
const entry = facts[tag];
if (!entry?.units) {
continue;
}
for (const series of Object.values(entry.units)) {
if (!Array.isArray(series)) {
continue;
}
for (const point of series) {
if (typeof point.val === 'number' && Number.isFinite(point.val)) {
points.push(point);
}
}
}
}
}
if (points.length === 0) {
return null;
}
const sorted = [...points].sort((left, right) => {
const leftDate = pointDate(left);
const rightDate = pointDate(right);
if (Number.isFinite(leftDate) && Number.isFinite(rightDate)) {
return rightDate - leftDate;
}
if (Number.isFinite(rightDate)) {
return 1;
}
if (Number.isFinite(leftDate)) {
return -1;
}
return 0;
});
return sorted[0]?.val ?? null;
}
async function getExchangeDirectory(fetchImpl?: FetchImpl) {
if (exchangeDirectoryCache && exchangeDirectoryCache.expiresAt > Date.now()) {
return exchangeDirectoryCache.value;
}
const payload = await fetchJson<ExchangeDirectoryPayload>(EXCHANGE_DIRECTORY_URL, fetchImpl);
const fields = payload.fields ?? [];
const cikIndex = fields.indexOf('cik');
const nameIndex = fields.indexOf('name');
const tickerIndex = fields.indexOf('ticker');
const exchangeIndex = fields.indexOf('exchange');
const directory = new Map<string, ExchangeDirectoryRecord>();
for (const row of payload.data ?? []) {
const ticker = asNormalizedString(row[tickerIndex]);
const cik = normalizeCik(row[cikIndex]);
const name = asNormalizedString(row[nameIndex]);
const exchange = asNormalizedString(row[exchangeIndex]);
if (!ticker || !cik || !name) {
continue;
}
directory.set(ticker.toUpperCase(), {
cik,
name,
ticker: ticker.toUpperCase(),
exchange
});
}
exchangeDirectoryCache = {
value: directory,
expiresAt: Date.now() + EXCHANGE_CACHE_TTL_MS
};
return directory;
}
async function getSubmissionByCik(cik: string, fetchImpl?: FetchImpl) {
const padded = toPaddedCik(cik);
if (!padded) {
return null;
}
const cached = submissionsCache.get(padded);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const payload = await fetchJson<SubmissionPayload>(`${SEC_SUBMISSIONS_BASE}/CIK${padded}.json`, fetchImpl);
submissionsCache.set(padded, {
value: payload,
expiresAt: Date.now() + SUBMISSIONS_CACHE_TTL_MS
});
return payload;
}
async function getCompanyFactsByCik(cik: string, fetchImpl?: FetchImpl) {
const padded = toPaddedCik(cik);
if (!padded) {
return null;
}
const cached = companyFactsCache.get(padded);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const payload = await fetchJson<CompanyFactsPayload>(`${SEC_COMPANY_FACTS_BASE}/CIK${padded}.json`, fetchImpl);
companyFactsCache.set(padded, {
value: payload,
expiresAt: Date.now() + COMPANY_FACTS_CACHE_TTL_MS
});
return payload;
}
export async function getSecCompanyProfile(
ticker: string,
options?: { fetchImpl?: FetchImpl }
): Promise<SecCompanyProfileResult | null> {
const normalizedTicker = ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
try {
const directory = await getExchangeDirectory(options?.fetchImpl);
const directoryRecord = directory.get(normalizedTicker) ?? null;
const cik = directoryRecord?.cik ?? null;
const [submission, companyFacts] = await Promise.all([
cik ? getSubmissionByCik(cik, options?.fetchImpl) : Promise.resolve(null),
cik ? getCompanyFactsByCik(cik, options?.fetchImpl) : Promise.resolve(null)
]);
const employeeCount = companyFacts
? pickLatestNumericFact(companyFacts, ['dei'], ['EntityNumberOfEmployees'])
: null;
const sharesOutstanding = companyFacts
? pickLatestNumericFact(companyFacts, ['dei'], ['EntityCommonStockSharesOutstanding', 'CommonStockSharesOutstanding'])
: null;
return {
ticker: normalizedTicker,
cik,
companyName: asNormalizedString(submission?.name) ?? directoryRecord?.name ?? null,
exchange: asNormalizedString(submission?.exchanges?.[0]) ?? directoryRecord?.exchange ?? null,
industry: asNormalizedString(submission?.sicDescription),
country: asNormalizedString(submission?.addresses?.business?.country)
?? asNormalizedString(submission?.addresses?.business?.stateOrCountryDescription),
website: asNormalizedString(submission?.website),
fiscalYearEnd: formatFiscalYearEnd(submission?.fiscalYearEnd ?? null),
employeeCount,
sharesOutstanding
};
} catch {
return null;
}
}
export function toCompanyProfile(input: SecCompanyProfileResult | null, description: string | null): CompanyProfile {
if (!input && !description) {
return {
description: null,
exchange: null,
industry: null,
country: null,
website: null,
fiscalYearEnd: null,
employeeCount: null,
source: 'unavailable'
};
}
return {
description,
exchange: input?.exchange ?? null,
industry: input?.industry ?? null,
country: input?.country ?? null,
website: input?.website ?? null,
fiscalYearEnd: input?.fiscalYearEnd ?? null,
employeeCount: input?.employeeCount ?? null,
source: 'sec_derived'
};
}
export function deriveValuationSnapshot(input: {
quote: number | null;
sharesOutstanding: number | null;
revenue: number | null;
cash: number | null;
debt: number | null;
netIncome: number | null;
}): CompanyValuationSnapshot {
const hasPrice = typeof input.quote === 'number' && Number.isFinite(input.quote) && input.quote > 0;
const hasShares = typeof input.sharesOutstanding === 'number' && Number.isFinite(input.sharesOutstanding) && input.sharesOutstanding > 0;
const marketCap = hasPrice && hasShares ? input.quote! * input.sharesOutstanding! : null;
const hasCash = typeof input.cash === 'number' && Number.isFinite(input.cash);
const hasDebt = typeof input.debt === 'number' && Number.isFinite(input.debt);
const enterpriseValue = marketCap !== null && hasCash && hasDebt
? marketCap + input.debt! - input.cash!
: null;
const hasRevenue = typeof input.revenue === 'number' && Number.isFinite(input.revenue) && input.revenue > 0;
const hasNetIncome = typeof input.netIncome === 'number' && Number.isFinite(input.netIncome) && input.netIncome > 0;
const trailingPe = marketCap !== null && hasNetIncome
? marketCap / input.netIncome!
: null;
const evToRevenue = enterpriseValue !== null && hasRevenue
? enterpriseValue / input.revenue!
: null;
const availableCount = [
input.sharesOutstanding,
marketCap,
enterpriseValue,
trailingPe,
evToRevenue
].filter((value) => typeof value === 'number' && Number.isFinite(value)).length;
return {
sharesOutstanding: input.sharesOutstanding,
marketCap,
enterpriseValue,
trailingPe,
evToRevenue,
evToEbitda: null,
source: availableCount === 0
? 'unavailable'
: availableCount >= 3
? 'derived'
: 'partial'
};
}
export const __secCompanyProfileInternals = {
formatFiscalYearEnd,
pickLatestNumericFact
};

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'bun:test';
import { __secDescriptionInternals, extractBusinessDescription } from './sec-description';
describe('sec description extraction', () => {
it('extracts Item 1 Business content from normalized filing text', () => {
const text = `
PART I
ITEM 1. BUSINESS
Microsoft develops and supports software, services, devices, and solutions worldwide. The company operates through productivity, cloud, and personal computing franchises. Its strategy centers on platform breadth, recurring commercial relationships, and enterprise adoption.
ITEM 1A. RISK FACTORS
Competition remains intense.
`.trim();
const description = extractBusinessDescription(text);
expect(description).toContain('Microsoft develops and supports software');
expect(description).not.toContain('RISK FACTORS');
});
it('falls back to the first meaningful paragraph when Item 1 is missing', () => {
const text = `
Forward-looking statements
This company designs semiconductors for accelerated computing workloads and sells related systems, networking products, and software. It serves hyperscale, enterprise, and sovereign demand across several end markets.
Additional introductory text.
`.trim();
expect(extractBusinessDescription(text)).toContain('designs semiconductors');
});
it('clips long extracted text on sentence boundaries', () => {
const clipped = __secDescriptionInternals.clipAtSentenceBoundary(`${'A short sentence. '.repeat(80)}`, 200);
expect(clipped.length).toBeLessThanOrEqual(200);
expect(clipped.endsWith('.')).toBe(true);
});
});

View File

@@ -0,0 +1,134 @@
import type { Filing } from '@/lib/types';
import { fetchPrimaryFilingText } from '@/lib/server/sec';
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const DESCRIPTION_CACHE_TTL_MS = 1000 * 60 * 60 * 6;
const DESCRIPTION_MAX_CHARS = 1_600;
const descriptionCache = new Map<string, CacheEntry<string | null>>();
function normalizeWhitespace(value: string) {
return value
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function clipAtSentenceBoundary(value: string, maxChars = DESCRIPTION_MAX_CHARS) {
if (value.length <= maxChars) {
return value;
}
const slice = value.slice(0, maxChars);
const sentenceBoundary = Math.max(
slice.lastIndexOf('. '),
slice.lastIndexOf('! '),
slice.lastIndexOf('? ')
);
if (sentenceBoundary > maxChars * 0.6) {
return slice.slice(0, sentenceBoundary + 1).trim();
}
const wordBoundary = slice.lastIndexOf(' ');
return (wordBoundary > maxChars * 0.7 ? slice.slice(0, wordBoundary) : slice).trim();
}
function cleanupExtractedSection(value: string) {
return clipAtSentenceBoundary(
normalizeWhitespace(
value
.replace(/\btable of contents\b/gi, ' ')
.replace(/\bitem\s+1\.?\s+business\b/gi, ' ')
.replace(/\bpart\s+i\b/gi, ' ')
)
);
}
function fallbackDescription(text: string) {
const paragraphs = text
.split(/\n{2,}/)
.map((paragraph) => normalizeWhitespace(paragraph))
.filter((paragraph) => paragraph.length >= 80)
.filter((paragraph) => !/^item\s+\d+[a-z]?\.?/i.test(paragraph))
.slice(0, 3);
if (paragraphs.length === 0) {
return null;
}
return clipAtSentenceBoundary(paragraphs.join(' '));
}
export function extractBusinessDescription(text: string) {
const normalized = normalizeWhitespace(text);
if (!normalized) {
return null;
}
const startMatch = /\bitem\s+1\.?\s+business\b/i.exec(normalized);
if (!startMatch || startMatch.index < 0) {
return fallbackDescription(normalized);
}
const afterStart = normalized.slice(startMatch.index + startMatch[0].length);
const endMatch = /\bitem\s+1a\.?\s+risk factors\b|\bitem\s+2\.?\s+properties\b|\bitem\s+2\.?\b/i.exec(afterStart);
const section = endMatch
? afterStart.slice(0, endMatch.index)
: afterStart;
const extracted = cleanupExtractedSection(section);
if (extracted.length >= 120) {
return extracted;
}
return fallbackDescription(normalized);
}
export async function getCompanyDescription(
filing: Pick<Filing, 'accession_number' | 'cik' | 'filing_url' | 'primary_document'> | null
) {
if (!filing) {
return null;
}
const cached = descriptionCache.get(filing.accession_number);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const document = await fetchPrimaryFilingText({
filingUrl: filing.filing_url,
cik: filing.cik,
accessionNumber: filing.accession_number,
primaryDocument: filing.primary_document ?? null
}, {
maxChars: 40_000
});
const description = document ? extractBusinessDescription(document.text) : null;
descriptionCache.set(filing.accession_number, {
value: description,
expiresAt: Date.now() + DESCRIPTION_CACHE_TTL_MS
});
return description;
} catch {
descriptionCache.set(filing.accession_number, {
value: null,
expiresAt: Date.now() + DESCRIPTION_CACHE_TTL_MS
});
return null;
}
}
export const __secDescriptionInternals = {
cleanupExtractedSection,
clipAtSentenceBoundary,
fallbackDescription
};

View File

@@ -672,6 +672,63 @@ export type CompanyAiReportDetail = CompanyAiReport & {
primaryDocument: string | null;
};
export type CompanyProfile = {
description: string | null;
exchange: string | null;
industry: string | null;
country: string | null;
website: string | null;
fiscalYearEnd: string | null;
employeeCount: number | null;
source: 'sec_derived' | 'unavailable';
};
export type CompanyValuationSnapshot = {
sharesOutstanding: number | null;
marketCap: number | null;
enterpriseValue: number | null;
trailingPe: number | null;
evToRevenue: number | null;
evToEbitda: number | null;
source: 'derived' | 'partial' | 'unavailable';
};
export type CompanyBullBear = {
source: 'ai_synthesized' | 'memo_fallback' | 'unavailable';
bull: string[];
bear: string[];
updatedAt: string | null;
};
export type RecentDevelopmentKind = '8-K' | '10-K' | '10-Q' | 'press_release' | 'news';
export type RecentDevelopmentItem = {
id: string;
kind: RecentDevelopmentKind;
title: string;
url: string | null;
source: string;
publishedAt: string;
summary: string | null;
accessionNumber: string | null;
};
export type RecentDevelopmentsWeeklySnapshot = {
summary: string;
highlights: string[];
itemCount: number;
startDate: string;
endDate: string;
updatedAt: string;
source: 'ai_synthesized' | 'heuristic';
};
export type RecentDevelopments = {
status: 'ready' | 'partial' | 'unavailable';
items: RecentDevelopmentItem[];
weeklySnapshot: RecentDevelopmentsWeeklySnapshot | null;
};
export type CompanyAnalysis = {
company: {
ticker: string;
@@ -708,6 +765,10 @@ export type CompanyAnalysis = {
debt: number | null;
netMargin: number | null;
};
companyProfile: CompanyProfile;
valuationSnapshot: CompanyValuationSnapshot;
bullBear: CompanyBullBear;
recentDevelopments: RecentDevelopments;
};
export type NavGroup = 'overview' | 'research' | 'portfolio';