From 103b18259872ae5ae27d6a34e072cbe89779ea34 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Thu, 23 Jul 2026 09:28:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20FundamentalsPanel=20=E2=80=94?= =?UTF-8?q?=20Reference=20Rails=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/ticker/FundamentalsPanel.tsx | 299 ++++++++++-------- 1 file changed, 171 insertions(+), 128 deletions(-) diff --git a/frontend/src/components/ticker/FundamentalsPanel.tsx b/frontend/src/components/ticker/FundamentalsPanel.tsx index 06e6097..3129f1f 100644 --- a/frontend/src/components/ticker/FundamentalsPanel.tsx +++ b/frontend/src/components/ticker/FundamentalsPanel.tsx @@ -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 = { - up: 'text-emerald-400', - flat: 'text-gray-400', - down: 'text-rose-400', -}; -const TONE_CELL: Record = { - 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 = { - 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,39 +104,35 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) { return (
-

Fundamentals

+

Fundamentals

{data.reads?.header && ( -

{data.reads.header}

+

{data.reads.header}

)}
{!hasAny ? ( -

No fundamentals reported yet.

+

No fundamentals reported yet.

) : ( <> - {/* Quarter tape — the panel's signature device */} -
-
- Quarter tape - Latest · read +
+
+ Operating trend +
+ {trendRows.map((r) => ( + + ))} +
-
- {tapeRows.map((row) => ( - - ))} -
-
- - {/* Balance & valuation — restrained peer strips */} -
- Balance & valuation -
- {valuationRows.map((row) => ( - - ))} +
+ Valuation & balance +
+ {valueRows.map((r) => ( + + ))} +
@@ -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 {children}; +} + +// ---- 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 = ( -
fmt(h.value)).join(', ') || 'no data'}`}> - {history.length === 0 && } - {history.map((h, i) => { - const latest = i === history.length - 1; - return ( - - {fmt(h.value)} - - ); - })} -
- ); + 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 ( -
- {/* mobile: label + read on one line, cells below (avoids narrow-width overflow) */} -
- {label} - {read ?? '—'} +
+
+ {label} + {pct(value)}
- {label} -
{cells}
- - {read ?? '—'} - +
{read ?? '—'}
+ {delta != null ? ( +
+ {refLabel} + + {signedPp(delta)} +
+ ) : ( + value == null &&
n/a
+ )}
); } -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 ( + + + + + + + ); +} + +// ---- 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 ( -
- {label} - {fmt(value)} -
- {industry ? ( - <> - {/* visible compact peer context (also the only peer info on mobile) */} - med {fmt(industry.median)} · {industry.peer_count}p - - {read ?? 'in line'} - - ) : ( - peers n/a - )} +
+
+ {label} + {fmt(value)}
+ {value == null ? ( +
n/a
+ ) : industry ? ( + <> +
{read ?? 'in line'}
+
+ 0 + + 100 +
+
+ median {fmt(industry.median)} · {industry.peer_count} peers +
+ + ) : ( +
peers n/a
+ )}
); } -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 ( - - - + + + + ); } +// ---- 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 ( -

+

{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 (

- - Next earnings + + Next earnings {next ? ( <> {shortDate(next.date)} {' · '} {next.session === 'unknown' ? 'TBD' : next.session} - · {when} + · {when} ) : ( - no date + no date )} {recent.length > 0 && ( - Last {recent.length} + Last {recent.length} {recent.slice().reverse().map((e, i) => )} )} @@ -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 ( - 0 ? '+' : ''}${e.surprise_pct}%` : ''}`} - aria-label={`${e.announce_date} ${label}`} - > + 0 ? '+' : ''}${e.surprise_pct}%` : ''}`} + aria-label={`${e.announce_date} ${label}`}> {arrow} );