From bd23f41a1d30b663d88dfc6d0db1af0e52f1d855 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Thu, 23 Jul 2026 08:56:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20FundamentalsPanel=20review=20?= =?UTF-8?q?=E2=80=94=20mobile,=20local=20dates,=20peer=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the static review + adds a dev-only visual harness: 1. Tape no longer overflows narrow mobile: each row stacks (label + read on one line, cells below) under sm, keeping the single-line grid on desktop. 2. Date-only strings (earnings, price_date) are parsed as LOCAL calendar dates, so a viewer west of UTC no longer sees the previous day. 3. Peer context is visible ("med X · Np") on every width and the percentile strip carries a full aria-label — no longer hover-only / desktop-only. 4. Per-metric provenance + freshness surfaced (SEC filings · latest quarter, filed date) replacing the removed panel-wide FMP label. 5. Same-day earnings render "today", not "in 0d". Harness: frontend/harness.html + src/dev/harness.tsx (dev-only, served at /harness.html by vite, not in the production build) render full / partial-insufficient-peer / empty fixtures for desktop + ~390px review. tsc -b passes. Co-Authored-By: Claude Opus 4.8 --- frontend/harness.html | 18 +++ .../components/ticker/FundamentalsPanel.tsx | 132 ++++++++++++------ frontend/src/dev/harness.tsx | 117 ++++++++++++++++ 3 files changed, 225 insertions(+), 42 deletions(-) create mode 100644 frontend/harness.html create mode 100644 frontend/src/dev/harness.tsx diff --git a/frontend/harness.html b/frontend/harness.html new file mode 100644 index 0000000..55b0e16 --- /dev/null +++ b/frontend/harness.html @@ -0,0 +1,18 @@ + + + + + + FundamentalsPanel harness + + + + + +
+ + + diff --git a/frontend/src/components/ticker/FundamentalsPanel.tsx b/frontend/src/components/ticker/FundamentalsPanel.tsx index 7c24c4c..06e6097 100644 --- a/frontend/src/components/ticker/FundamentalsPanel.tsx +++ b/frontend/src/components/ticker/FundamentalsPanel.tsx @@ -23,6 +23,11 @@ const TONE_CELL: Record = { 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', +}; const POSITIVE_READS = new Set([ 'accelerating', 'improving', 'above peers', 'buying back', 'attractively valued', @@ -58,6 +63,16 @@ function money(v: number | null | undefined): string { return `$${v.toFixed(0)}`; } +/** 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). */ +function parseLocalDate(s: string): Date { + const [y, m, d] = s.split('-').map(Number); + return new Date(y, (m ?? 1) - 1, d ?? 1); +} +function shortDate(s: string): string { + return parseLocalDate(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + export function FundamentalsPanel({ data }: FundamentalsPanelProps) { const metrics = useMemo( () => Object.fromEntries((data.metrics ?? []).map((m) => [m.key, m])), @@ -67,10 +82,13 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) { 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: string; label: string; fmt: (v: number | null) => string }[] = [ + 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 }, @@ -91,10 +109,10 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) { return (
-
+

Fundamentals

{data.reads?.header && ( -

{data.reads.header}

+

{data.reads.header}

)}
@@ -106,11 +124,11 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) { {/* Quarter tape — the panel's signature device */}
-
+
Quarter tape - Latest · read + Latest · read
-
+
{tapeRows.map((row) => ( @@ -121,17 +139,15 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) { {/* Balance & valuation — restrained peer strips */}
Balance & valuation -
+
{valuationRows.map((row) => ( ))}
- {val?.price_date && ( -

- Valuation at {new Date(val.price_date).toLocaleDateString()} close · market cap {money(val.market_cap_est)} est. -

- )}
+ + )}
@@ -144,23 +160,33 @@ function TapeRow({ label, metric, read, fmt }: { }) { 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)} + + ); + })} +
+ ); return ( -
- {label} -
fmt(h.value)).join(', ')}`}> - {history.length === 0 && } - {history.map((h, i) => { - const latest = i === history.length - 1; - return ( - - {fmt(h.value)} - - ); - })} +
+ {/* mobile: label + read on one line, cells below (avoids narrow-width overflow) */} +
+ {label} + {read ?? '—'}
- {read ?? '—'} + {label} +
{cells}
+ + {read ?? '—'} +
); } @@ -171,13 +197,15 @@ function ValuationRow({ label, value, industry, read, fmt }: { }) { 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'} ) : ( @@ -188,35 +216,55 @@ function ValuationRow({ label, value, industry, read, fmt }: { ); } -function PercentileStrip({ industry, tone }: { industry: MetricIndustry; tone: Tone }) { +function PercentileStrip({ industry, tone, label, fmt }: { + industry: MetricIndustry; tone: Tone; label: string; fmt: (v: number | null) => string; +}) { const p = Math.max(0, Math.min(100, industry.favorable_percentile)); - const bar = tone === 'up' ? 'bg-emerald-400' : tone === 'down' ? 'bg-rose-400' : 'bg-gray-400'; return ( - - - - - - - + + + ); } +function Provenance({ provenance, priceDate, marketCap }: { + provenance: MetricItem | null; priceDate: string | null; marketCap: number | null; +}) { + if (!provenance && !priceDate) return null; + return ( +

+ {provenance?.period_end && ( + <>SEC filings · latest {shortDate(provenance.period_end)} + {provenance.filed_date && <> (filed {shortDate(provenance.filed_date)})} + )} + {priceDate && ( + <>{provenance?.period_end ? ' · ' : ''}Valuation at {shortDate(priceDate)} close · market cap {money(marketCap)} est. + )} +

+ ); +} + 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; return (
Next earnings {next ? ( <> - {new Date(next.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + {shortDate(next.date)} {' · '} {next.session === 'unknown' ? 'TBD' : next.session} - · in {next.days_until}d + · {when} ) : ( no date diff --git a/frontend/src/dev/harness.tsx b/frontend/src/dev/harness.tsx new file mode 100644 index 0000000..a116067 --- /dev/null +++ b/frontend/src/dev/harness.tsx @@ -0,0 +1,117 @@ +/* Dev-only visual harness for FundamentalsPanel. Served at /harness.html by + * `vite`. Not imported by the app. Renders the three key states so desktop and + * mobile can be eyeballed with representative fixtures. */ +import { createRoot } from 'react-dom/client'; +import '../styles/globals.css'; +import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel'; +import type { FundamentalResponse, MetricItem } from '../lib/types'; + +function h(period: string, value: number | null) { + return { period_end: period, value }; +} +const P = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-28']; + +function metric(key: string, value: number | null, hist: (number | null)[], + industry: MetricItem['industry'] = null): MetricItem { + return { + key: key as MetricItem['key'], value, + history: hist.map((v, i) => h(P[i], v)), + industry, period_end: '2026-03-28', filed_date: '2026-05-01', source: 'sec', + }; +} +const ind = (median: number, favorable_percentile: number) => + ({ label: 'SIC 35 peers', median, favorable_percentile, peer_count: 12 }); + +const legacy = { + pe_ratio: null, revenue_growth: null, earnings_surprise: null, market_cap: null, + next_earnings_date: null, fetched_at: null, unavailable_fields: {}, +}; + +const full: FundamentalResponse = { + symbol: 'AAPL', ...legacy, + earnings: { + next: { date: '2026-08-03', 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 }, + { announce_date: '2026-02-01', period_end: '2025-12-31', eps_estimate: 2.6, eps_actual: 2.4, surprise_pct: -7.7 }, + { announce_date: '2026-05-01', period_end: '2026-03-28', eps_estimate: 1.5, eps_actual: 1.65, surprise_pct: 10.0 }, + ], + }, + metrics: [ + metric('revenue_growth_yoy', 18, [8, 11, 15, 18], ind(11, 82)), + metric('eps_growth_yoy', 24, [10, 18, 22, 24], ind(15, 70)), + 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('share_count_change_yoy', -1.7, [-2.4, -2.2, -2.3, -1.7], null), + ], + valuation: { + pe: 29.2, fcf_yield: 3.8, market_cap_est: 3.2e12, + pe_industry: ind(23.5, 30), fcf_yield_industry: ind(3.1, 70), price_date: '2026-05-01', + }, + reads: { + header: 'growth accelerating · margins improving · valuation priced above peers', + by_key: { + revenue_growth_yoy: 'accelerating', eps_growth_yoy: 'accelerating', + operating_margin: 'improving', fcf_margin: 'improving', + share_count_change_yoy: 'buying back', net_debt_to_ebitda: 'conservative leverage', + pe: 'priced above peers', fcf_yield: 'above peers', net_debt: null, + }, + }, +}; + +const partial: FundamentalResponse = { + symbol: 'NEWCO', ...legacy, + earnings: { next: { date: '2026-08-03', 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), + metric('operating_margin', 25, [24, 24, 25, 25], null), + metric('fcf_margin', null, [null, null, null, null], null), + metric('net_debt', null, [], null), + metric('net_debt_to_ebitda', 1.9, [1.7, 1.8, 1.9, 1.9], null), + metric('share_count_change_yoy', 2.1, [1.8, 2.0, 2.0, 2.1], null), + ], + valuation: { + pe: 15.2, fcf_yield: null, market_cap_est: 5.4e8, + pe_industry: null, fcf_yield_industry: null, price_date: '2026-05-01', + }, + reads: { + header: 'growth steady · margins stable', + by_key: { + revenue_growth_yoy: 'steady', operating_margin: 'stable', + share_count_change_yoy: '2.1% dilution', net_debt_to_ebitda: null, + pe: null, fcf_yield: null, eps_growth_yoy: null, fcf_margin: null, net_debt: null, + }, + }, +}; + +const empty: FundamentalResponse = { + symbol: 'ADR', ...legacy, + earnings: { next: null, recent: [] }, + metrics: [ + 'revenue_growth_yoy', 'eps_growth_yoy', 'operating_margin', 'fcf_margin', + 'net_debt', 'net_debt_to_ebitda', 'share_count_change_yoy', + ].map((k) => metric(k, null, [])), + valuation: null, + reads: { header: null, by_key: {} }, +}; + +function Case({ title, data }: { title: string; data: FundamentalResponse }) { + return ( +
+
{title}
+ +
+ ); +} + +createRoot(document.getElementById('root')!).render( +
+ + + +
, +);