feat(frontend): FundamentalsPanel — Reference Rails redesign
Replace the four-cell quarter tape (too many equally-weighted numbers) with one comparison rail per metric, so the panel answers "improving? sound? fairly valued?" instead of asking the reader to decode it. - Operating trend (revenue/EPS growth, operating/FCF margin, share count): a rail centered on a truthful reference — prior quarter (growth), prior-period average (margins), or zero (share count) — with a dot at the current delta and a bar back to the reference, plus a shaded neutral band (backend's +-2pp / +-1pp / +-1% rules). Value, read, reference label, and signed delta stay visible; per-quarter history drops out of the default view. - Valuation & balance (net debt/EBITDA, P/E, FCF yield): a 0-100 favorable- percentile rail with the peer median fixed at 50; right is always more favorable (percentile is polarity-aware). median + peer_count shown. - Not a progress bar: reference line, not a 100% target. - Horizon tokens: cyan #6EC9DB favorable / coral #EF9182 adverse / #5D6373 track, replacing emerald/rose. Two columns on desktop, single column (rows stack) on mobile. Null -> n/a with no rail; insufficient peers -> "peers n/a", no track. - Kept: compact earnings line, provenance footer, local-date parsing, aria-labels on every rail. tsc -b passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,39 +10,31 @@ interface FundamentalsPanelProps {
|
||||
data: FundamentalResponse;
|
||||
}
|
||||
|
||||
/** Positive / neutral / negative styling, always paired with the read text. */
|
||||
type Tone = 'up' | 'flat' | 'down';
|
||||
/** Favorable / neutral / adverse — always paired with the read text. */
|
||||
type Tone = 'good' | 'flat' | 'bad';
|
||||
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
up: 'text-emerald-400',
|
||||
flat: 'text-gray-400',
|
||||
down: 'text-rose-400',
|
||||
};
|
||||
const TONE_CELL: Record<Tone, string> = {
|
||||
up: 'bg-emerald-400/10 text-emerald-200 ring-emerald-400/20',
|
||||
flat: 'bg-white/5 text-gray-200 ring-white/10',
|
||||
down: 'bg-rose-400/10 text-rose-200 ring-rose-400/20',
|
||||
};
|
||||
const TONE_BAR: Record<Tone, string> = {
|
||||
up: 'bg-emerald-400',
|
||||
flat: 'bg-gray-400',
|
||||
down: 'bg-rose-400',
|
||||
// Horizon tokens (native to the rest of the product).
|
||||
const HZ = {
|
||||
text: '#EDEEF3',
|
||||
muted: '#9AA0B0',
|
||||
track: '#5D6373',
|
||||
fav: '#6EC9DB', // cyan
|
||||
adv: '#EF9182', // coral
|
||||
};
|
||||
const toneColor = (t: Tone) => (t === 'good' ? HZ.fav : t === 'bad' ? HZ.adv : HZ.muted);
|
||||
|
||||
const POSITIVE_READS = new Set([
|
||||
'accelerating', 'improving', 'above peers', 'buying back', 'attractively valued',
|
||||
'conservative leverage',
|
||||
'accelerating', 'improving', 'above peers', 'above own average', 'buying back',
|
||||
'attractively valued', 'conservative leverage',
|
||||
]);
|
||||
const NEGATIVE_READS = new Set([
|
||||
'decelerating', 'deteriorating', 'priced above peers', 'elevated leverage',
|
||||
'below peers',
|
||||
'decelerating', 'deteriorating', 'priced above peers', 'elevated leverage', 'below peers',
|
||||
]);
|
||||
|
||||
function readTone(read: string | null | undefined): Tone {
|
||||
if (!read) return 'flat';
|
||||
if (POSITIVE_READS.has(read)) return 'up';
|
||||
if (NEGATIVE_READS.has(read)) return 'down';
|
||||
if (read.includes('dilution')) return 'down';
|
||||
if (POSITIVE_READS.has(read)) return 'good';
|
||||
if (NEGATIVE_READS.has(read)) return 'bad';
|
||||
if (read.includes('dilution')) return 'bad';
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
@@ -62,9 +54,11 @@ function money(v: number | null | undefined): string {
|
||||
if (abs >= 1e6) return `$${(v / 1e6).toFixed(1)}M`;
|
||||
return `$${v.toFixed(0)}`;
|
||||
}
|
||||
function signedPp(v: number): string {
|
||||
return `${v >= 0 ? '+' : ''}${Math.round(v * 10) / 10}pp`;
|
||||
}
|
||||
|
||||
/** Parse a YYYY-MM-DD string as a LOCAL calendar date (avoids the UTC-midnight
|
||||
* off-by-one that shows the previous day west of UTC). */
|
||||
/** Parse YYYY-MM-DD as a LOCAL calendar date (no UTC off-by-one west of UTC). */
|
||||
function parseLocalDate(s: string): Date {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
return new Date(y, (m ?? 1) - 1, d ?? 1);
|
||||
@@ -81,22 +75,22 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||
const reads = data.reads?.by_key ?? {};
|
||||
const val = data.valuation;
|
||||
const earnings = data.earnings;
|
||||
|
||||
// per-metric provenance/freshness (all SEC snapshot metrics share the latest filing)
|
||||
const provenance = (data.metrics ?? []).find((m) => m.period_end) ?? null;
|
||||
|
||||
const hasAny = (data.metrics ?? []).some((m) => m.value != null) || !!val || !!earnings?.next
|
||||
|| (earnings?.recent?.length ?? 0) > 0;
|
||||
|
||||
const tapeRows = [
|
||||
{ key: 'revenue_growth_yoy', label: 'Revenue growth', fmt: pct },
|
||||
{ key: 'eps_growth_yoy', label: 'EPS growth', fmt: pct },
|
||||
{ key: 'operating_margin', label: 'Operating margin', fmt: pct },
|
||||
{ key: 'fcf_margin', label: 'FCF margin', fmt: pct },
|
||||
{ key: 'share_count_change_yoy', label: 'Share count', fmt: pct },
|
||||
// Operating trend: value vs a truthful reference (prior quarter / prior avg / zero).
|
||||
const trendRows: { key: string; label: string; kind: 'growth' | 'margin' | 'share' }[] = [
|
||||
{ key: 'revenue_growth_yoy', label: 'Revenue growth', kind: 'growth' },
|
||||
{ key: 'eps_growth_yoy', label: 'EPS growth', kind: 'growth' },
|
||||
{ key: 'operating_margin', label: 'Operating margin', kind: 'margin' },
|
||||
{ key: 'fcf_margin', label: 'FCF margin', kind: 'margin' },
|
||||
{ key: 'share_count_change_yoy', label: 'Share count', kind: 'share' },
|
||||
];
|
||||
|
||||
const valuationRows: {
|
||||
// Valuation & balance: favorable percentile vs peer median.
|
||||
const valueRows: {
|
||||
label: string; value: number | null; industry: MetricIndustry | null;
|
||||
readKey: string; fmt: (v: number | null) => string;
|
||||
}[] = [
|
||||
@@ -110,41 +104,37 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||
return (
|
||||
<section className="glass p-5" aria-label="Fundamentals">
|
||||
<div className="mb-1 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
||||
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
|
||||
<h3 className="text-xs font-medium uppercase tracking-widest" style={{ color: HZ.muted }}>Fundamentals</h3>
|
||||
{data.reads?.header && (
|
||||
<p className="text-[11.5px] text-gray-400 sm:text-right">{data.reads.header}</p>
|
||||
<p className="text-[11.5px] sm:text-right" style={{ color: HZ.muted }}>{data.reads.header}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasAny ? (
|
||||
<p className="mt-3 text-sm text-gray-500">No fundamentals reported yet.</p>
|
||||
<p className="mt-3 text-sm" style={{ color: HZ.muted }}>No fundamentals reported yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<EarningsStrip earnings={earnings} />
|
||||
|
||||
{/* Quarter tape — the panel's signature device */}
|
||||
<div className="mt-4">
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Quarter tape</span>
|
||||
<span className="hidden text-[10px] uppercase tracking-widest text-gray-600 sm:inline">Latest · read</span>
|
||||
</div>
|
||||
<div className="space-y-2 sm:space-y-1.5">
|
||||
{tapeRows.map((row) => (
|
||||
<TapeRow key={row.key} label={row.label} metric={metrics[row.key]}
|
||||
read={reads[row.key]} fmt={row.fmt} />
|
||||
<div className="mt-4 grid gap-x-8 gap-y-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<SectionLabel>Operating trend</SectionLabel>
|
||||
<div className="mt-2 space-y-3.5">
|
||||
{trendRows.map((r) => (
|
||||
<TrendRow key={r.key} label={r.label} kind={r.kind}
|
||||
metric={metrics[r.key]} read={reads[r.key]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance & valuation — restrained peer strips */}
|
||||
<div className="mt-4">
|
||||
<span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Balance & valuation</span>
|
||||
<div className="mt-1.5 space-y-2.5 sm:space-y-2">
|
||||
{valuationRows.map((row) => (
|
||||
<ValuationRow key={row.label} {...row} read={reads[row.readKey]} />
|
||||
<div>
|
||||
<SectionLabel>Valuation & balance</SectionLabel>
|
||||
<div className="mt-2 space-y-3.5">
|
||||
{valueRows.map((r) => (
|
||||
<ValueRow key={r.label} {...r} read={reads[r.readKey]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Provenance provenance={provenance} priceDate={val?.price_date ?? null}
|
||||
marketCap={val?.market_cap_est ?? null} />
|
||||
@@ -154,90 +144,147 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function TapeRow({ label, metric, read, fmt }: {
|
||||
label: string; metric: MetricItem | undefined; read: string | null | undefined;
|
||||
fmt: (v: number | null) => string;
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[10px] font-medium uppercase tracking-widest" style={{ color: HZ.muted }}>{children}</span>;
|
||||
}
|
||||
|
||||
// ---- operating-trend row: value vs reference, shown as a delta rail ----------
|
||||
|
||||
function TrendRow({ label, kind, metric, read }: {
|
||||
label: string; kind: 'growth' | 'margin' | 'share';
|
||||
metric: MetricItem | undefined; read: string | null | undefined;
|
||||
}) {
|
||||
const history = metric?.history ?? [];
|
||||
const tone = readTone(read);
|
||||
const cells = (
|
||||
<div className="flex justify-end gap-1" role="img"
|
||||
aria-label={`${label} last ${history.length}: ${history.map((h) => fmt(h.value)).join(', ') || 'no data'}`}>
|
||||
{history.length === 0 && <span className="num text-xs text-gray-600">—</span>}
|
||||
{history.map((h, i) => {
|
||||
const latest = i === history.length - 1;
|
||||
const value = metric?.value ?? null;
|
||||
const history = (metric?.history ?? []).map((h) => h.value).filter((v): v is number => v != null);
|
||||
|
||||
// reference + neutral band per the backend's deterministic rules
|
||||
let ref: number | null = null;
|
||||
let refLabel = '';
|
||||
let halfRange = 8;
|
||||
let neutral = 2;
|
||||
if (kind === 'growth') {
|
||||
ref = history.length >= 2 ? history[history.length - 2] : null;
|
||||
refLabel = ref != null ? `prior ${pct(ref)}` : '';
|
||||
halfRange = 8; neutral = 2;
|
||||
} else if (kind === 'margin') {
|
||||
const prior = history.slice(0, -1);
|
||||
ref = prior.length ? prior.reduce((a, b) => a + b, 0) / prior.length : null;
|
||||
refLabel = ref != null ? `avg ${pct(ref)}` : '';
|
||||
halfRange = 4; neutral = 1;
|
||||
} else {
|
||||
ref = 0; refLabel = 'vs zero'; halfRange = 5; neutral = 1;
|
||||
}
|
||||
const delta = value != null && ref != null ? value - ref : null;
|
||||
|
||||
return (
|
||||
<span key={i}
|
||||
className={`num rounded px-1.5 py-0.5 text-[11px] ring-1 ${latest ? TONE_CELL[tone] : 'bg-white/5 text-gray-400 ring-white/5'}`}>
|
||||
{fmt(h.value)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm" style={{ color: HZ.muted }}>{label}</span>
|
||||
<span className="num text-sm" style={{ color: HZ.text }}>{pct(value)}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="sm:grid sm:grid-cols-[minmax(0,7rem)_1fr_auto] sm:items-center sm:gap-3">
|
||||
{/* mobile: label + read on one line, cells below (avoids narrow-width overflow) */}
|
||||
<div className="flex items-baseline justify-between gap-2 sm:hidden">
|
||||
<span className="text-sm text-gray-400">{label}</span>
|
||||
<span className={`text-[11.5px] font-medium ${TONE_TEXT[tone]}`}>{read ?? '—'}</span>
|
||||
<div className="text-[11.5px] font-medium" style={{ color: toneColor(tone) }}>{read ?? '—'}</div>
|
||||
{delta != null ? (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="w-20 shrink-0 text-[10px]" style={{ color: HZ.muted }}>{refLabel}</span>
|
||||
<DeltaRail delta={delta} halfRange={halfRange} neutral={neutral} tone={tone}
|
||||
ariaLabel={`${label} ${pct(value)}, ${refLabel || 'reference'}, change ${signedPp(delta)}, ${read ?? 'no read'}`} />
|
||||
<span className="num w-14 shrink-0 text-right text-[10px]" style={{ color: toneColor(tone) }}>{signedPp(delta)}</span>
|
||||
</div>
|
||||
<span className="hidden truncate text-sm text-gray-400 sm:block">{label}</span>
|
||||
<div className="mt-1 sm:mt-0">{cells}</div>
|
||||
<span className={`hidden text-right text-[11.5px] font-medium sm:block sm:w-24 ${TONE_TEXT[tone]}`}>
|
||||
{read ?? '—'}
|
||||
</span>
|
||||
) : (
|
||||
value == null && <div className="mt-1 text-[10px]" style={{ color: HZ.track }}>n/a</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValuationRow({ label, value, industry, read, fmt }: {
|
||||
/** A comparison rail centered on a reference line (not a progress bar): a dot at
|
||||
* the current delta with a bar connecting it back to the reference. */
|
||||
function DeltaRail({ delta, halfRange, neutral, tone, ariaLabel }: {
|
||||
delta: number; halfRange: number; neutral: number; tone: Tone; ariaLabel: string;
|
||||
}) {
|
||||
const clamped = Math.max(-halfRange, Math.min(halfRange, delta));
|
||||
const pos = 50 + (clamped / halfRange) * 50; // %
|
||||
const barLeft = Math.min(50, pos);
|
||||
const barWidth = Math.abs(pos - 50);
|
||||
const bandHalf = (neutral / halfRange) * 50;
|
||||
const color = toneColor(tone);
|
||||
return (
|
||||
<span className="relative block h-1.5 flex-1 rounded-full" role="img" aria-label={ariaLabel}
|
||||
style={{ background: 'rgba(93,99,115,0.22)' }}>
|
||||
<span className="absolute inset-y-0 rounded-full" aria-hidden
|
||||
style={{ left: `${50 - bandHalf}%`, width: `${2 * bandHalf}%`, background: 'rgba(93,99,115,0.4)' }} />
|
||||
<span className="absolute inset-y-[-2px] w-px" aria-hidden style={{ left: '50%', background: 'rgba(237,238,243,0.55)' }} />
|
||||
<span className="absolute inset-y-0 rounded-full" aria-hidden style={{ left: `${barLeft}%`, width: `${barWidth}%`, background: color }} />
|
||||
<span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full" aria-hidden
|
||||
style={{ left: `${pos}%`, background: color, boxShadow: '0 0 0 2px #11131C' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- valuation/balance row: favorable percentile vs peer median -------------
|
||||
|
||||
function ValueRow({ label, value, industry, read, fmt }: {
|
||||
label: string; value: number | null; industry: MetricIndustry | null;
|
||||
read: string | null | undefined; fmt: (v: number | null) => string;
|
||||
}) {
|
||||
const tone = readTone(read);
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,8.5rem)_auto_1fr] items-center gap-x-3 gap-y-0.5">
|
||||
<span className="truncate text-sm text-gray-400">{label}</span>
|
||||
<span className="num text-sm text-gray-100">{fmt(value)}</span>
|
||||
<div className="flex items-center justify-end gap-2 text-right">
|
||||
{industry ? (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm" style={{ color: HZ.muted }}>{label}</span>
|
||||
<span className="num text-sm" style={{ color: HZ.text }}>{fmt(value)}</span>
|
||||
</div>
|
||||
{value == null ? (
|
||||
<div className="mt-1 text-[10px]" style={{ color: HZ.track }}>n/a</div>
|
||||
) : industry ? (
|
||||
<>
|
||||
{/* visible compact peer context (also the only peer info on mobile) */}
|
||||
<span className="text-[11px] text-gray-500">med {fmt(industry.median)} · {industry.peer_count}p</span>
|
||||
<PercentileStrip industry={industry} tone={tone} label={label} fmt={fmt} />
|
||||
<span className={`text-[11.5px] font-medium ${TONE_TEXT[tone]}`}>{read ?? 'in line'}</span>
|
||||
<div className="text-[11.5px] font-medium" style={{ color: toneColor(tone) }}>{read ?? 'in line'}</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="w-3 text-[9px]" style={{ color: HZ.track }}>0</span>
|
||||
<PercentileRail percentile={industry.favorable_percentile} tone={tone}
|
||||
ariaLabel={`${label}: ${industry.favorable_percentile}th favorable percentile vs ${industry.label}, median ${fmt(industry.median)}, ${industry.peer_count} peers`} />
|
||||
<span className="w-6 text-right text-[9px]" style={{ color: HZ.track }}>100</span>
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[10px]" style={{ color: HZ.muted }}>
|
||||
median {fmt(industry.median)} · {industry.peer_count} peers
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[11px] text-gray-600">peers n/a</span>
|
||||
<div className="text-[11px]" style={{ color: HZ.track }}>peers n/a</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PercentileStrip({ industry, tone, label, fmt }: {
|
||||
industry: MetricIndustry; tone: Tone; label: string; fmt: (v: number | null) => string;
|
||||
/** Percentile rail 0-100 with the peer median fixed at 50; right = more favorable
|
||||
* (the percentile is already polarity-aware). */
|
||||
function PercentileRail({ percentile, tone, ariaLabel }: {
|
||||
percentile: number; tone: Tone; ariaLabel: string;
|
||||
}) {
|
||||
const p = Math.max(0, Math.min(100, industry.favorable_percentile));
|
||||
const p = Math.max(0, Math.min(100, percentile));
|
||||
const barLeft = Math.min(50, p);
|
||||
const barWidth = Math.abs(p - 50);
|
||||
const color = toneColor(tone);
|
||||
return (
|
||||
<span
|
||||
className="relative hidden h-1.5 w-16 rounded-full bg-white/10 sm:block"
|
||||
role="img"
|
||||
aria-label={`${label}: ${industry.favorable_percentile}th favorable percentile vs ${industry.label}, median ${fmt(industry.median)}, ${industry.peer_count} peers`}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 rounded-full ${TONE_BAR[tone]}`} style={{ width: `${p}%` }} />
|
||||
<span className="absolute inset-y-[-2px] left-1/2 w-px bg-white/40" aria-hidden />
|
||||
<span className="relative block h-1.5 flex-1 rounded-full" role="img" aria-label={ariaLabel}
|
||||
style={{ background: 'rgba(93,99,115,0.22)' }}>
|
||||
<span className="absolute inset-y-[-2px] w-px" aria-hidden style={{ left: '50%', background: 'rgba(237,238,243,0.55)' }} />
|
||||
<span className="absolute inset-y-0 rounded-full" aria-hidden style={{ left: `${barLeft}%`, width: `${barWidth}%`, background: color }} />
|
||||
<span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full" aria-hidden
|
||||
style={{ left: `${p}%`, background: color, boxShadow: '0 0 0 2px #11131C' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- earnings + provenance (unchanged behavior) ----------------------------
|
||||
|
||||
function Provenance({ provenance, priceDate, marketCap }: {
|
||||
provenance: MetricItem | null; priceDate: string | null; marketCap: number | null;
|
||||
}) {
|
||||
if (!provenance && !priceDate) return null;
|
||||
if (!provenance?.period_end && !priceDate) return null;
|
||||
return (
|
||||
<p className="mt-4 border-t border-white/10 pt-2 text-[10px] leading-relaxed text-gray-600">
|
||||
<p className="mt-4 border-t border-white/10 pt-2 text-[10px] leading-relaxed" style={{ color: HZ.track }}>
|
||||
{provenance?.period_end && (
|
||||
<>SEC filings · latest {shortDate(provenance.period_end)}
|
||||
{provenance.filed_date && <> (filed {shortDate(provenance.filed_date)})</>}</>
|
||||
@@ -252,27 +299,25 @@ function Provenance({ provenance, priceDate, marketCap }: {
|
||||
function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings'] }) {
|
||||
const next = earnings?.next;
|
||||
const recent = earnings?.recent ?? [];
|
||||
const when = next
|
||||
? next.days_until === 0 ? 'today' : `in ${next.days_until}d`
|
||||
: null;
|
||||
const when = next ? (next.days_until === 0 ? 'today' : `${next.days_until}d`) : null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||
<span className="text-sm text-gray-300">
|
||||
<span className="text-gray-500">Next earnings </span>
|
||||
<span className="text-sm" style={{ color: HZ.text }}>
|
||||
<span style={{ color: HZ.muted }}>Next earnings </span>
|
||||
{next ? (
|
||||
<>
|
||||
{shortDate(next.date)}
|
||||
{' · '}
|
||||
<span className="uppercase">{next.session === 'unknown' ? 'TBD' : next.session}</span>
|
||||
<span className="text-gray-500"> · {when}</span>
|
||||
<span style={{ color: HZ.muted }}> · {when}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-500">no date</span>
|
||||
<span style={{ color: HZ.muted }}>no date</span>
|
||||
)}
|
||||
</span>
|
||||
{recent.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-widest text-gray-600">Last {recent.length}</span>
|
||||
<span className="text-[10px] uppercase tracking-widest" style={{ color: HZ.track }}>Last {recent.length}</span>
|
||||
{recent.slice().reverse().map((e, i) => <EarningsBar key={i} e={e} />)}
|
||||
</span>
|
||||
)}
|
||||
@@ -282,15 +327,13 @@ function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings']
|
||||
|
||||
function EarningsBar({ e }: { e: EarningsRecent }) {
|
||||
const beat = e.eps_actual != null && e.eps_estimate != null ? e.eps_actual - e.eps_estimate : null;
|
||||
const tone: Tone = beat == null ? 'flat' : beat > 0 ? 'up' : beat < 0 ? 'down' : 'flat';
|
||||
const tone: Tone = beat == null ? 'flat' : beat > 0 ? 'good' : beat < 0 ? 'bad' : 'flat';
|
||||
const label = beat == null ? 'n/a' : beat > 0 ? 'beat' : beat < 0 ? 'miss' : 'in line';
|
||||
const arrow = beat == null ? '·' : beat > 0 ? '▲' : beat < 0 ? '▼' : '–';
|
||||
return (
|
||||
<span
|
||||
className={`num rounded px-1 text-[10px] ring-1 ${TONE_CELL[tone]}`}
|
||||
<span className="num text-xs" style={{ color: toneColor(tone) }}
|
||||
title={`${e.announce_date}: ${label}${e.surprise_pct != null ? ` ${e.surprise_pct > 0 ? '+' : ''}${e.surprise_pct}%` : ''}`}
|
||||
aria-label={`${e.announce_date} ${label}`}
|
||||
>
|
||||
aria-label={`${e.announce_date} ${label}`}>
|
||||
{arrow}
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user