feat(frontend): finish fundamentals reference rails

This commit is contained in:
2026-07-23 10:59:50 +02:00
parent 480baf762f
commit 71d21a45b6
3 changed files with 207 additions and 135 deletions
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, type ReactNode } from 'react';
import type {
EarningsRecent,
FundamentalResponse,
@@ -13,7 +13,7 @@ interface FundamentalsPanelProps {
/** Favorable / neutral / adverse — always paired with the read text. */
type Tone = 'good' | 'flat' | 'bad';
// Horizon tokens (native to the rest of the product).
// Horizon tokens.
const HZ = {
text: '#EDEEF3',
muted: '#9AA0B0',
@@ -57,6 +57,22 @@ function money(v: number | null | undefined): string {
function signedPp(v: number): string {
return `${v >= 0 ? '+' : ''}${Math.round(v * 10) / 10}pp`;
}
function capitalize(s: string): string {
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
}
function finiteOrNull(v: number | null | undefined): number | null {
return v != null && Number.isFinite(v) ? v : null;
}
function latestHistory(metric: MetricItem | undefined): number[] {
const run: number[] = [];
const history = metric?.history ?? [];
for (let i = history.length - 1; i >= 0; i -= 1) {
const value = finiteOrNull(history[i].value);
if (value == null) break;
run.unshift(value);
}
return run;
}
/** Parse YYYY-MM-DD as a LOCAL calendar date (no UTC off-by-one west of UTC). */
function parseLocalDate(s: string): Date {
@@ -80,16 +96,14 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
const hasAny = (data.metrics ?? []).some((m) => m.value != null) || !!val || !!earnings?.next
|| (earnings?.recent?.length ?? 0) > 0;
// 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' },
{ key: 'share_count_change_yoy', label: 'Share count YoY', kind: 'share' },
];
// Valuation & balance: favorable percentile vs peer median.
const valueRows: {
label: string; value: number | null; industry: MetricIndustry | null;
readKey: string; fmt: (v: number | null) => string;
@@ -103,23 +117,25 @@ 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" style={{ color: HZ.muted }}>Fundamentals</h3>
{data.reads?.header && (
<p className="text-[11.5px] sm:text-right" style={{ color: HZ.muted }}>{data.reads.header}</p>
)}
</div>
<h3 className="text-[10px] font-medium uppercase tracking-widest" style={{ color: HZ.muted }}>Fundamentals</h3>
{data.reads?.header ? (
<p className="mt-0.5 text-[15px] leading-snug" style={{ color: HZ.text }}>
{capitalize(data.reads.header)}
</p>
) : !hasAny ? (
<p className="mt-1 text-[15px] leading-snug" style={{ color: HZ.muted }}>
No fundamentals reported yet.
</p>
) : null}
{!hasAny ? (
<p className="mt-3 text-sm" style={{ color: HZ.muted }}>No fundamentals reported yet.</p>
) : (
{hasAny && (
<>
<EarningsStrip earnings={earnings} />
<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">
<SectionHead label="Operating trend" axis="less favorable ← ref → more favorable" />
<div className="mt-2.5 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]} />
@@ -127,8 +143,8 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
</div>
</div>
<div>
<SectionLabel>Valuation &amp; balance</SectionLabel>
<div className="mt-2 space-y-3.5">
<SectionHead label="Valuation & balance" axis="less favorable ← median → more favorable" />
<div className="mt-2.5 space-y-3.5">
{valueRows.map((r) => (
<ValueRow key={r.label} {...r} read={reads[r.readKey]} />
))}
@@ -144,147 +160,158 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <span className="text-[10px] font-medium uppercase tracking-widest" style={{ color: HZ.muted }}>{children}</span>;
function SectionHead({ label, axis }: { label: string; axis: string }) {
return (
<div className="flex flex-wrap items-baseline justify-between gap-x-2">
<span className="text-[10px] font-medium uppercase tracking-widest" style={{ color: HZ.muted }}>{label}</span>
<span className="text-[11px]" style={{ color: HZ.track }}>{axis}</span>
</div>
);
}
// ---- operating-trend row: value vs reference, shown as a delta rail ----------
function Bullet({ label, value, rail, comparison }: {
label: string; value: ReactNode; rail: ReactNode | null; comparison: ReactNode;
}) {
return (
<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-[15px]" style={{ color: HZ.text }}>{value}</span>
</div>
{rail && <div className="mt-1.5">{rail}</div>}
<div className={rail ? 'mt-1 text-[11.5px] leading-snug' : 'mt-1.5 text-[11.5px] leading-snug'}>
{comparison}
</div>
</div>
);
}
// ---- operating-trend row (delta vs reference, favorable = right) ------------
function TrendRow({ label, kind, metric, read }: {
label: string; kind: 'growth' | 'margin' | 'share';
metric: MetricItem | undefined; read: string | null | undefined;
}) {
const tone = readTone(read);
const value = metric?.value ?? null;
const history = (metric?.history ?? []).map((h) => h.value).filter((v): v is number => v != null);
const value = finiteOrNull(metric?.value);
const history = latestHistory(metric);
// reference + neutral band per the backend's deterministic rules
let ref: number | null = null;
let refLabel = '';
let refWord = '';
let halfRange = 8;
let neutral = 2;
let favSign = 1; // +1: higher is favorable; -1: lower is favorable
if (kind === 'growth') {
ref = history.length >= 2 ? history[history.length - 2] : null;
refLabel = ref != null ? `prior ${pct(ref)}` : '';
halfRange = 8; neutral = 2;
refWord = 'prior'; halfRange = 8; neutral = 2; favSign = 1;
} 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;
refWord = 'avg'; halfRange = 4; neutral = 1; favSign = 1;
} else {
ref = 0; refLabel = 'vs zero'; halfRange = 5; neutral = 1;
ref = 0; refWord = ''; halfRange = 5; neutral = 1; favSign = -1; // buyback (negative) is favorable
}
const delta = value != null && ref != null ? value - ref : null;
return (
<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>
<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>
) : (
value == null && <div className="mt-1 text-[10px]" style={{ color: HZ.track }}>n/a</div>
const comparison = value == null ? (
<span style={{ color: HZ.track }}>n/a</span>
) : delta == null ? (
<span style={{ color: HZ.track }}>history n/a</span>
) : (
<span style={{ color: toneColor(tone) }}>
{kind !== 'share' && (
<span className="num">{signedPp(delta)} vs {refWord} · </span>
)}
</div>
{read ?? '—'}
</span>
);
const rail = delta != null
? <DeltaRail favOffset={favSign * delta} halfRange={halfRange} neutral={neutral} tone={tone}
ariaLabel={`${label} ${pct(value)}, ${delta != null ? `${signedPp(delta)} vs reference` : ''}, ${read ?? 'no read'}`} />
: null;
return <Bullet label={label} value={pct(value)} rail={rail} comparison={comparison} />;
}
/** 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;
/** Comparison rail centered on a reference line (not a progress bar). favOffset > 0
* is favorable and moves the dot RIGHT for every metric. */
function DeltaRail({ favOffset, halfRange, neutral, tone, ariaLabel }: {
favOffset: 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 clamped = Math.max(-halfRange, Math.min(halfRange, favOffset));
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}
<span className="relative block h-1.5 min-w-[7rem] 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 className="absolute inset-y-0 rounded-full" aria-hidden style={{ left: `${barLeft}%`, width: `${barWidth}%`, background: toneColor(tone) }} />
<Dot pos={pos} tone={tone} />
</span>
);
}
// ---- valuation/balance row: favorable percentile vs peer median -------------
// ---- valuation/balance row (favorable percentile vs 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>
<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 ? (
<>
<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>
</>
) : (
<div className="text-[11px]" style={{ color: HZ.track }}>peers n/a</div>
)}
</div>
const safeValue = finiteOrNull(value);
const rail = safeValue == null || !industry
? null
: <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`} />;
const comparison = safeValue == null ? (
<span style={{ color: HZ.track }}>n/a</span>
) : industry ? (
<span style={{ color: toneColor(tone) }}>
{read ?? 'in line'}<span style={{ color: HZ.muted }}> · median {fmt(industry.median)} · {industry.peer_count} peers</span>
</span>
) : (
<span style={{ color: HZ.track }}>peers n/a</span>
);
return <Bullet label={label} value={fmt(safeValue)} rail={rail} comparison={comparison} />;
}
/** Percentile rail 0-100 with the peer median fixed at 50; right = more favorable
* (the percentile is already polarity-aware). */
/** 0-100 favorable-percentile rail with the peer median fixed at 50; right = more favorable. */
function PercentileRail({ percentile, tone, ariaLabel }: {
percentile: number; tone: Tone; ariaLabel: string;
}) {
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 block h-1.5 flex-1 rounded-full" role="img" aria-label={ariaLabel}
<span className="relative block h-1.5 min-w-[7rem] 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 className="absolute inset-y-0 rounded-full" aria-hidden style={{ left: `${barLeft}%`, width: `${barWidth}%`, background: toneColor(tone) }} />
<Dot pos={p} tone={tone} />
</span>
);
}
// ---- earnings + provenance (unchanged behavior) ----------------------------
function Dot({ pos, tone }: { pos: number; tone: Tone }) {
return (
<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: toneColor(tone), boxShadow: '0 0 0 2px #11131C' }} />
);
}
// ---- earnings + provenance -------------------------------------------------
function Provenance({ provenance, priceDate, marketCap }: {
provenance: MetricItem | null; priceDate: string | null; marketCap: number | null;
}) {
if (!provenance?.period_end && !priceDate) return null;
return (
<p className="mt-4 border-t border-white/10 pt-2 text-[10px] leading-relaxed" style={{ color: HZ.track }}>
<p className="mt-4 border-t border-white/10 pt-2 text-[11px] leading-relaxed" style={{ color: HZ.track }}>
{provenance?.period_end && (
<>SEC filings · latest {shortDate(provenance.period_end)}
{provenance.filed_date && <> (filed {shortDate(provenance.filed_date)})</>}</>
@@ -301,7 +328,7 @@ function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings']
const recent = earnings?.recent ?? [];
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">
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
<span className="text-sm" style={{ color: HZ.text }}>
<span style={{ color: HZ.muted }}>Next earnings </span>
{next ? (
@@ -315,26 +342,56 @@ function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings']
<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" style={{ color: HZ.track }}>Last {recent.length}</span>
{recent.slice().reverse().map((e, i) => <EarningsBar key={i} e={e} />)}
</span>
)}
{recent.length > 0 && <SurpriseSpark recent={recent} />}
</div>
);
}
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 ? '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 ? '▼' : '';
/** Four tiny diverging bars around a zero baseline: beat above (cyan), miss below
* (coral), height ~ |surprise %|. Reads as a beat/miss history at a glance. */
function SurpriseSpark({ recent }: { recent: EarningsRecent[] }) {
const ordered = recent.slice().reverse();
const description = ordered.map((e) => {
const surprise = e.surprise_pct;
const amount = surprise != null ? ` ${surprise > 0 ? '+' : ''}${surprise}%` : '';
return `${e.announce_date} ${surpriseLabel(e)}${amount}`;
}).join(', ');
return (
<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}`}>
{arrow}
<span className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-widest" style={{ color: HZ.track }}>
EPS surprises
</span>
<span
className="relative flex h-7 items-center gap-1.5 px-0.5"
role="img"
tabIndex={0}
title={description}
aria-label={`Recent EPS surprises, oldest to newest: ${description}`}
>
<span className="absolute inset-x-0 top-1/2 h-px" aria-hidden style={{ background: 'rgba(93,99,115,0.5)' }} />
{ordered.map((e, i) => <SurpriseBar key={i} e={e} />)}
</span>
</span>
);
}
function surpriseLabel(e: EarningsRecent): string {
const beat = e.eps_actual != null && e.eps_estimate != null ? e.eps_actual - e.eps_estimate : null;
return beat == null ? 'n/a' : beat > 0 ? 'beat' : beat < 0 ? 'miss' : 'in line';
}
function SurpriseBar({ 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 ? 'good' : beat < 0 ? 'bad' : 'flat';
const s = e.surprise_pct;
const mag = s != null ? Math.min(Math.abs(s), 15) / 15 : 0; // cap at 15%
const h = beat == null ? 2 : 3 + mag * 9; // px
const up = (beat ?? 0) >= 0;
return (
<span className="relative z-[1] block h-7 w-2"
title={`${e.announce_date}: ${surpriseLabel(e)}${s != null ? ` ${s > 0 ? '+' : ''}${s}%` : ''}`}>
<span className="absolute inset-x-0 rounded-[1px]" aria-hidden
style={{ height: h, background: toneColor(tone), ...(up ? { bottom: '50%' } : { top: '50%' }) }} />
</span>
);
}
+14 -3
View File
@@ -11,6 +11,17 @@ function h(period: string, value: number | null) {
}
const P = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-28'];
function dateFromToday(days: number): string {
const date = new Date();
date.setHours(12, 0, 0, 0);
date.setDate(date.getDate() + days);
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
}
function metric(key: string, value: number | null, hist: (number | null)[],
industry: MetricItem['industry'] = null): MetricItem {
return {
@@ -30,7 +41,7 @@ const legacy = {
const full: FundamentalResponse = {
symbol: 'AAPL', ...legacy,
earnings: {
next: { date: '2026-08-03', session: 'amc', days_until: 12 },
next: { date: dateFromToday(12), session: 'amc', days_until: 12 },
recent: [
{ announce_date: '2025-08-01', period_end: '2025-06-30', eps_estimate: 1.4, eps_actual: 1.6, surprise_pct: 14.3 },
{ announce_date: '2025-11-01', period_end: '2025-09-30', eps_estimate: 1.7, eps_actual: 1.9, surprise_pct: 11.8 },
@@ -44,7 +55,7 @@ const full: FundamentalResponse = {
metric('operating_margin', 32, [30, 31, 31, 32], ind(22, 88)),
metric('fcf_margin', 28, [24, 25, 27, 28], ind(18, 80)),
metric('net_debt', 16.2e9, [46e9, 44e9, 24e9, 16.2e9], null),
metric('net_debt_to_ebitda', 1.4, [0.3, 0.3, 0.2, 0.1], ind(2.1, 68)),
metric('net_debt_to_ebitda', 1.4, [1.9, 1.7, 1.5, 1.4], ind(2.1, 68)),
metric('share_count_change_yoy', -1.7, [-2.4, -2.2, -2.3, -1.7], null),
],
valuation: {
@@ -64,7 +75,7 @@ const full: FundamentalResponse = {
const partial: FundamentalResponse = {
symbol: 'NEWCO', ...legacy,
earnings: { next: { date: '2026-08-03', session: 'unknown', days_until: 0 }, recent: [] },
earnings: { next: { date: dateFromToday(0), session: 'unknown', days_until: 0 }, recent: [] },
metrics: [
metric('revenue_growth_yoy', 12, [null, 8, 10, 12], null),
metric('eps_growth_yoy', null, [null, null, null, null], null),