Rebuild company overview analysis page

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

View File

@@ -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>
);
}