fix(frontend): FundamentalsPanel review — mobile, local dates, peer a11y

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:56:45 +02:00
co-authored by Claude Opus 4.8
parent 9172c1a699
commit bd23f41a1d
3 changed files with 225 additions and 42 deletions
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FundamentalsPanel harness</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-[#0a0b11] text-gray-100 font-sans">
<div id="root"></div>
<script type="module" src="/src/dev/harness.tsx"></script>
</body>
</html>
@@ -23,6 +23,11 @@ const TONE_CELL: Record<Tone, string> = {
flat: 'bg-white/5 text-gray-200 ring-white/10', flat: 'bg-white/5 text-gray-200 ring-white/10',
down: 'bg-rose-400/10 text-rose-200 ring-rose-400/20', 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',
};
const POSITIVE_READS = new Set([ const POSITIVE_READS = new Set([
'accelerating', 'improving', 'above peers', 'buying back', 'attractively valued', 'accelerating', 'improving', 'above peers', 'buying back', 'attractively valued',
@@ -58,6 +63,16 @@ function money(v: number | null | undefined): string {
return `$${v.toFixed(0)}`; 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) { export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
const metrics = useMemo( const metrics = useMemo(
() => Object.fromEntries((data.metrics ?? []).map((m) => [m.key, m])), () => Object.fromEntries((data.metrics ?? []).map((m) => [m.key, m])),
@@ -67,10 +82,13 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
const val = data.valuation; const val = data.valuation;
const earnings = data.earnings; 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 const hasAny = (data.metrics ?? []).some((m) => m.value != null) || !!val || !!earnings?.next
|| (earnings?.recent?.length ?? 0) > 0; || (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: 'revenue_growth_yoy', label: 'Revenue growth', fmt: pct },
{ key: 'eps_growth_yoy', label: 'EPS growth', fmt: pct }, { key: 'eps_growth_yoy', label: 'EPS growth', fmt: pct },
{ key: 'operating_margin', label: 'Operating margin', fmt: pct }, { key: 'operating_margin', label: 'Operating margin', fmt: pct },
@@ -91,10 +109,10 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
return ( return (
<section className="glass p-5" aria-label="Fundamentals"> <section className="glass p-5" aria-label="Fundamentals">
<div className="mb-1 flex items-baseline justify-between gap-3"> <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 text-gray-500">Fundamentals</h3>
{data.reads?.header && ( {data.reads?.header && (
<p className="truncate text-right text-[11.5px] text-gray-400">{data.reads.header}</p> <p className="text-[11.5px] text-gray-400 sm:text-right">{data.reads.header}</p>
)} )}
</div> </div>
@@ -106,11 +124,11 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
{/* Quarter tape — the panel's signature device */} {/* Quarter tape — the panel's signature device */}
<div className="mt-4"> <div className="mt-4">
<div className="mb-1.5 grid grid-cols-[minmax(0,1fr)_auto] items-baseline"> <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="text-[10px] font-medium uppercase tracking-widest text-gray-500">Quarter tape</span>
<span className="text-[10px] uppercase tracking-widest text-gray-600">Latest · read</span> <span className="hidden text-[10px] uppercase tracking-widest text-gray-600 sm:inline">Latest · read</span>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-2 sm:space-y-1.5">
{tapeRows.map((row) => ( {tapeRows.map((row) => (
<TapeRow key={row.key} label={row.label} metric={metrics[row.key]} <TapeRow key={row.key} label={row.label} metric={metrics[row.key]}
read={reads[row.key]} fmt={row.fmt} /> read={reads[row.key]} fmt={row.fmt} />
@@ -121,17 +139,15 @@ export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
{/* Balance & valuation — restrained peer strips */} {/* Balance & valuation — restrained peer strips */}
<div className="mt-4"> <div className="mt-4">
<span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Balance &amp; valuation</span> <span className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Balance &amp; valuation</span>
<div className="mt-1.5 space-y-2"> <div className="mt-1.5 space-y-2.5 sm:space-y-2">
{valuationRows.map((row) => ( {valuationRows.map((row) => (
<ValuationRow key={row.label} {...row} read={reads[row.readKey]} /> <ValuationRow key={row.label} {...row} read={reads[row.readKey]} />
))} ))}
</div> </div>
{val?.price_date && (
<p className="mt-2 text-[10px] text-gray-600">
Valuation at {new Date(val.price_date).toLocaleDateString()} close · market cap {money(val.market_cap_est)} est.
</p>
)}
</div> </div>
<Provenance provenance={provenance} priceDate={val?.price_date ?? null}
marketCap={val?.market_cap_est ?? null} />
</> </>
)} )}
</section> </section>
@@ -144,11 +160,9 @@ function TapeRow({ label, metric, read, fmt }: {
}) { }) {
const history = metric?.history ?? []; const history = metric?.history ?? [];
const tone = readTone(read); const tone = readTone(read);
return ( const cells = (
<div className="grid grid-cols-[minmax(0,7rem)_1fr_auto] items-center gap-3">
<span className="truncate text-sm text-gray-400">{label}</span>
<div className="flex justify-end gap-1" role="img" <div className="flex justify-end gap-1" role="img"
aria-label={`${label}: ${history.map((h) => fmt(h.value)).join(', ')}`}> 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.length === 0 && <span className="num text-xs text-gray-600"></span>}
{history.map((h, i) => { {history.map((h, i) => {
const latest = i === history.length - 1; const latest = i === history.length - 1;
@@ -160,7 +174,19 @@ function TapeRow({ label, metric, read, fmt }: {
); );
})} })}
</div> </div>
<span className={`w-24 text-right text-[11.5px] font-medium ${TONE_TEXT[tone]}`}>{read ?? '—'}</span> );
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>
<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>
</div> </div>
); );
} }
@@ -171,13 +197,15 @@ function ValuationRow({ label, value, industry, read, fmt }: {
}) { }) {
const tone = readTone(read); const tone = readTone(read);
return ( return (
<div className="grid grid-cols-[minmax(0,9rem)_auto_1fr] items-center gap-3"> <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="truncate text-sm text-gray-400">{label}</span>
<span className="num text-sm text-gray-100">{fmt(value)}</span> <span className="num text-sm text-gray-100">{fmt(value)}</span>
<div className="flex items-center justify-end gap-3 text-right"> <div className="flex items-center justify-end gap-2 text-right">
{industry ? ( {industry ? (
<> <>
<PercentileStrip industry={industry} tone={tone} /> {/* 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> <span className={`text-[11.5px] font-medium ${TONE_TEXT[tone]}`}>{read ?? 'in line'}</span>
</> </>
) : ( ) : (
@@ -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 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 ( return (
<span className="hidden items-center gap-1.5 sm:flex" <span
title={`${industry.label}: median ${industry.median}, ${industry.peer_count} peers`}> className="relative hidden h-1.5 w-16 rounded-full bg-white/10 sm:block"
<span className="relative h-1 w-16 rounded-full bg-white/10"> role="img"
<span className="absolute inset-y-0 left-0 rounded-full" style={{ width: `${p}%` }}> aria-label={`${label}: ${industry.favorable_percentile}th favorable percentile vs ${industry.label}, median ${fmt(industry.median)}, ${industry.peer_count} peers`}
<span className={`block h-full w-full rounded-full ${bar}`} /> >
</span> <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/30" aria-hidden /> <span className="absolute inset-y-[-2px] left-1/2 w-px bg-white/40" aria-hidden />
</span>
</span> </span>
); );
} }
function Provenance({ provenance, priceDate, marketCap }: {
provenance: MetricItem | null; priceDate: string | null; marketCap: number | null;
}) {
if (!provenance && !priceDate) return null;
return (
<p className="mt-4 border-t border-white/10 pt-2 text-[10px] leading-relaxed text-gray-600">
{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.</>
)}
</p>
);
}
function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings'] }) { function EarningsStrip({ earnings }: { earnings: FundamentalResponse['earnings'] }) {
const next = earnings?.next; const next = earnings?.next;
const recent = earnings?.recent ?? []; const recent = earnings?.recent ?? [];
const when = next
? next.days_until === 0 ? 'today' : `in ${next.days_until}d`
: null;
return ( 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">
<span className="text-sm text-gray-300"> <span className="text-sm text-gray-300">
<span className="text-gray-500">Next earnings </span> <span className="text-gray-500">Next earnings </span>
{next ? ( {next ? (
<> <>
{new Date(next.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} {shortDate(next.date)}
{' · '} {' · '}
<span className="uppercase">{next.session === 'unknown' ? 'TBD' : next.session}</span> <span className="uppercase">{next.session === 'unknown' ? 'TBD' : next.session}</span>
<span className="text-gray-500"> · in {next.days_until}d</span> <span className="text-gray-500"> · {when}</span>
</> </>
) : ( ) : (
<span className="text-gray-500">no date</span> <span className="text-gray-500">no date</span>
+117
View File
@@ -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 (
<div>
<div className="mb-1.5 text-[11px] uppercase tracking-widest text-gray-500">{title}</div>
<FundamentalsPanel data={data} />
</div>
);
}
createRoot(document.getElementById('root')!).render(
<div className="mx-auto max-w-md space-y-8 p-4">
<Case title="Full" data={full} />
<Case title="Partial · insufficient peers" data={partial} />
<Case title="Empty" data={empty} />
</div>,
);