Rebuild company overview analysis page

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -346,7 +346,7 @@ export default function PortfolioPage() {
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
Analysis
Overview
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}
@@ -444,7 +444,7 @@ export default function PortfolioPage() {
onFocus={() => prefetchResearchTicker(holding.ticker)}
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Analysis
Overview
</Link>
<Link
href={`/financials?ticker=${holding.ticker}`}

View File

@@ -408,7 +408,7 @@ function ResearchPageContent() {
subtitle="Build an investor-grade company dossier with evidence-linked memo sections, uploads, and a packet view."
activeTicker={ticker || null}
breadcrumbs={[
{ label: 'Analysis', href: ticker ? `/analysis?ticker=${encodeURIComponent(ticker)}` : '/analysis' },
{ label: 'Overview', href: ticker ? `/analysis?ticker=${encodeURIComponent(ticker)}` : '/analysis' },
{ label: 'Research' }
]}
actions={(
@@ -430,14 +430,14 @@ function ResearchPageContent() {
onFocus={() => prefetchResearchTicker(ticker)}
className="inline-flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
Open analysis
Open overview
</Link>
) : null}
</div>
)}
>
{!ticker ? (
<Panel title="Select a company" subtitle="Open this page with `?ticker=NVDA` or use the Coverage and Analysis surfaces to pivot into research.">
<Panel title="Select a company" subtitle="Open this page with `?ticker=NVDA` or use the Coverage and Overview surfaces to pivot into research.">
<p className="text-sm text-[color:var(--terminal-muted)]">This workspace is company-first by design and activates once a ticker is selected.</p>
</Panel>
) : null}

View File

@@ -384,7 +384,7 @@ export default function WatchlistPage() {
onFocus={() => prefetchResearchTicker(item.ticker)}
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
Analyze
Open overview
<ArrowRight className="size-3" />
</Link>
<Link
@@ -539,7 +539,7 @@ export default function WatchlistPage() {
onFocus={() => prefetchResearchTicker(item.ticker)}
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
>
Analyze
Open overview
<ArrowRight className="size-3" />
</Link>
<Link

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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