Rebuild company overview analysis page
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user