Rebuild company overview analysis page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
]}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
69
components/analysis/analysis-toolbar.tsx
Normal file
69
components/analysis/analysis-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
components/analysis/bull-bear-panel.tsx
Normal file
56
components/analysis/bull-bear-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
components/analysis/company-overview-card.tsx
Normal file
81
components/analysis/company-overview-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
components/analysis/company-profile-facts-table.tsx
Normal file
70
components/analysis/company-profile-facts-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
components/analysis/price-history-card.tsx
Normal file
119
components/analysis/price-history-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
components/analysis/recent-developments-section.tsx
Normal file
58
components/analysis/recent-developments-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
components/analysis/valuation-facts-table.tsx
Normal file
63
components/analysis/valuation-facts-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/analysis/valuation-stat-grid.tsx
Normal file
36
components/analysis/valuation-stat-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
components/analysis/weekly-snapshot-card.tsx
Normal file
35
components/analysis/weekly-snapshot-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}, {
|
||||
|
||||
@@ -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',
|
||||
|
||||
85
lib/server/company-overview-synthesis.test.ts
Normal file
85
lib/server/company-overview-synthesis.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
273
lib/server/company-overview-synthesis.ts
Normal file
273
lib/server/company-overview-synthesis.ts
Normal 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
|
||||
};
|
||||
86
lib/server/recent-developments.test.ts
Normal file
86
lib/server/recent-developments.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
161
lib/server/recent-developments.ts
Normal file
161
lib/server/recent-developments.ts
Normal 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
|
||||
};
|
||||
67
lib/server/sec-company-profile.test.ts
Normal file
67
lib/server/sec-company-profile.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
380
lib/server/sec-company-profile.ts
Normal file
380
lib/server/sec-company-profile.ts
Normal 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
|
||||
};
|
||||
41
lib/server/sec-description.test.ts
Normal file
41
lib/server/sec-description.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
134
lib/server/sec-description.ts
Normal file
134
lib/server/sec-description.ts
Normal 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
|
||||
};
|
||||
61
lib/types.ts
61
lib/types.ts
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user