diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-17 13:36:57 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-17 13:36:57 -0700 |
| commit | c3f19f79f66054dc3b3a98999ea38b0f05248e06 (patch) | |
| tree | 2d3b551838bfc8abeb52be20350b729377bceea5 /frontend | |
| parent | 6b8e9470d5b40030172b0413f0c5875fcbe65595 (diff) | |
Refine overview ratios and shell
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/page.tsx | 48 | ||||
| -rw-r--r-- | frontend/app/prism-shell.css | 170 | ||||
| -rw-r--r-- | frontend/components/prism/ChartCard.tsx | 7 | ||||
| -rw-r--r-- | frontend/components/prism/Sidebar.tsx | 6 | ||||
| -rw-r--r-- | frontend/components/prism/TickerHeader.tsx | 14 | ||||
| -rw-r--r-- | frontend/lib/overview.ts | 13 | ||||
| -rw-r--r-- | frontend/types/api.ts | 16 |
7 files changed, 197 insertions, 77 deletions
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 02bd706..41408b0 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -377,12 +377,12 @@ function SignalCard({ overview }: { overview: TickerOverview }) { <h2 className="psm-card-title">Readthrough</h2> </div> </div> - <div className="psm-signal-grid"> + <div className="psm-signal-list"> {overview.signals.map((signal) => ( - <article key={signal.key} className={`psm-signal ${signalTone(signal.state)}`}> + <article key={signal.key} className={`psm-signal-row ${signalTone(signal.state)}`}> <span className="psm-signal-key">{signal.key}</span> - <span className="psm-signal-value">{signal.value}</span> <span className="psm-signal-copy">{signal.description}</span> + <span className="psm-signal-value">{signal.value}</span> </article> ))} </div> @@ -392,12 +392,14 @@ function SignalCard({ overview }: { overview: TickerOverview }) { function DataStatusCard({ overview, missingFields }: { overview: TickerOverview; missingFields: string[] }) { const entries = Object.entries(overview.meta.sources).slice(0, 6); + const visibleMissing = missingFields.slice(0, 4); + const hiddenMissingCount = Math.max(0, missingFields.length - visibleMissing.length); return ( <section className="psm-card"> <div className="psm-card-head"> <div> - <div className="psm-eyebrow">Data Quality</div> + <div className="psm-eyebrow">Coverage</div> <h2 className="psm-card-title">Coverage</h2> </div> <span className={`psm-status-chip${overview.meta.is_partial ? " partial" : ""}`}>{overview.meta.status}</span> @@ -405,7 +407,8 @@ function DataStatusCard({ overview, missingFields }: { overview: TickerOverview; <p className="psm-quality-copy">{availableFieldSummary(overview)}</p> {overview.meta.is_partial ? ( <div className="psm-stack"> - {missingFields.length ? missingFields.slice(0, 8).map((field) => <span key={field} className="psm-field-tag missing">{field}</span>) : null} + {missingFields.length ? visibleMissing.map((field) => <span key={field} className="psm-field-tag missing">{field}</span>) : null} + {hiddenMissingCount ? <span className="psm-field-tag missing">+{hiddenMissingCount} more</span> : null} </div> ) : null} <div className="psm-source-list"> @@ -433,7 +436,7 @@ function ProfileCard({ overview }: { overview: TickerOverview }) { <div className="psm-card-head"> <div> <div className="psm-eyebrow">Company Profile</div> - <h2 className="psm-card-title">Context</h2> + <h2 className="psm-card-title">Company Profile</h2> </div> </div> <div className="psm-profile-list"> @@ -474,7 +477,7 @@ function ShortInterestCard({ overview }: { overview: TickerOverview }) { <div className="psm-card-head"> <div> <div className="psm-eyebrow">Short Interest</div> - <h2 className="psm-card-title">Pressure</h2> + <h2 className="psm-card-title">Short Interest</h2> </div> </div> <div className="psm-detail-grid"> @@ -488,21 +491,38 @@ function ShortInterestCard({ overview }: { overview: TickerOverview }) { } function StatsCard({ overview }: { overview: TickerOverview }) { + const referenceRows = [ + { label: "Market Cap", value: fmtLarge(overview.stats.market_cap), missing: overview.stats.market_cap == null }, + { label: "P/E TTM", value: overview.stats.trailing_pe == null ? "-" : `${fmtNumber(overview.stats.trailing_pe)}x`, missing: overview.stats.trailing_pe == null }, + { label: "EPS TTM", value: fmtCurrency(overview.stats.trailing_eps), missing: overview.stats.trailing_eps == null }, + { label: "P/B", value: overview.ratios.price_to_book == null ? "-" : `${fmtNumber(overview.ratios.price_to_book)}x`, missing: overview.ratios.price_to_book == null }, + { label: "P/S", value: overview.ratios.price_to_sales == null ? "-" : `${fmtNumber(overview.ratios.price_to_sales)}x`, missing: overview.ratios.price_to_sales == null }, + { label: "EV/Sales", value: overview.ratios.ev_to_sales == null ? "-" : `${fmtNumber(overview.ratios.ev_to_sales)}x`, missing: overview.ratios.ev_to_sales == null }, + { label: "EV/EBITDA", value: overview.ratios.ev_to_ebitda == null ? "-" : `${fmtNumber(overview.ratios.ev_to_ebitda)}x`, missing: overview.ratios.ev_to_ebitda == null }, + { label: "Gross Margin", value: fmtPct(overview.ratios.gross_margin_ttm), missing: overview.ratios.gross_margin_ttm == null }, + { label: "Op Margin", value: fmtPct(overview.ratios.operating_margin_ttm), missing: overview.ratios.operating_margin_ttm == null }, + { label: "Net Margin", value: fmtPct(overview.ratios.net_margin_ttm), missing: overview.ratios.net_margin_ttm == null }, + { label: "ROE", value: fmtPct(overview.ratios.roe_ttm), missing: overview.ratios.roe_ttm == null }, + { label: "ROA", value: fmtPct(overview.ratios.roa_ttm), missing: overview.ratios.roa_ttm == null }, + { label: "ROIC", value: fmtPct(overview.ratios.roic_ttm), missing: overview.ratios.roic_ttm == null }, + { label: "D/E", value: overview.ratios.debt_to_equity == null ? "-" : `${fmtNumber(overview.ratios.debt_to_equity)}x`, missing: overview.ratios.debt_to_equity == null }, + { label: "Current Ratio", value: overview.ratios.current_ratio == null ? "-" : `${fmtNumber(overview.ratios.current_ratio)}x`, missing: overview.ratios.current_ratio == null }, + { label: "Dividend Yield", value: fmtPct(overview.ratios.dividend_yield_ttm), missing: overview.ratios.dividend_yield_ttm == null }, + { label: "Payout Ratio", value: fmtPct(overview.ratios.dividend_payout_ratio_ttm), missing: overview.ratios.dividend_payout_ratio_ttm == null } + ]; + return ( <section className="psm-card"> <div className="psm-card-head"> <div> - <div className="psm-eyebrow">Overview Stats</div> + <div className="psm-eyebrow">Reference</div> <h2 className="psm-card-title">Reference</h2> </div> </div> <div className="psm-stat-list"> - <StatRow label="Market Cap" value={fmtLarge(overview.stats.market_cap)} missing={overview.stats.market_cap == null} /> - <StatRow label="P/E TTM" value={fmtNumber(overview.stats.trailing_pe)} missing={overview.stats.trailing_pe == null} /> - <StatRow label="EPS TTM" value={fmtCurrency(overview.stats.trailing_eps)} missing={overview.stats.trailing_eps == null} /> - <StatRow label="Volume" value={fmtNumber(overview.stats.volume, 0)} missing={overview.stats.volume == null} /> - <StatRow label="Avg Volume" value={fmtNumber(overview.stats.average_volume, 0)} missing={overview.stats.average_volume == null} /> - <StatRow label="Beta" value={fmtNumber(overview.stats.beta)} missing={overview.stats.beta == null} /> + {referenceRows.map((row) => ( + <StatRow key={row.label} label={row.label} value={row.value} missing={row.missing} /> + ))} </div> </section> ); diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css index 9b0e1b5..b6372c2 100644 --- a/frontend/app/prism-shell.css +++ b/frontend/app/prism-shell.css @@ -1,6 +1,6 @@ .prism-app { display: grid; - grid-template-columns: 256px minmax(0, 1fr); + grid-template-columns: 284px minmax(0, 1fr); min-height: 100vh; } @@ -192,22 +192,22 @@ .psm-watch-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: var(--sp-2); + grid-template-columns: minmax(0, 1fr) 34px; + gap: var(--sp-3); align-items: center; border-bottom: 1px solid var(--line-1); } .psm-watch-select { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: var(--sp-2); + grid-template-columns: minmax(0, 1fr) minmax(82px, auto) minmax(52px, auto); + column-gap: var(--sp-5); align-items: center; width: 100%; border: 0; background: transparent; color: var(--fg-2); - padding: 10px 0; + padding: 12px 0; } .psm-watch-select:hover, @@ -221,13 +221,22 @@ .psm-watch-main { min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; text-align: left; } +.psm-watch-cell { + min-width: 0; +} + .psm-watch-symbol { color: var(--fg-1); + font-family: var(--font-mono); font-size: var(--fs-14); font-weight: 500; + letter-spacing: 0.04em; } .psm-watch-date, @@ -240,6 +249,11 @@ font-size: var(--fs-13); } +.psm-watch-date { + line-height: 1.2; + white-space: nowrap; +} + .psm-watch-price, .psm-watch-change, .psm-quote-line, @@ -261,13 +275,13 @@ } .psm-watch-remove { - width: 26px; - height: 26px; + width: 30px; + height: 30px; border: 1px solid var(--line-2); - border-radius: var(--r-full); + border-radius: var(--r-2); background: transparent; color: var(--fg-4); - margin-right: 2px; + justify-self: end; } .psm-watch-remove:hover { @@ -484,9 +498,9 @@ .psm-ticker-head { display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(220px, 0.75fr) auto; + grid-template-columns: minmax(0, 1.4fr) minmax(240px, 0.75fr) minmax(220px, auto); gap: var(--sp-5); - align-items: end; + align-items: start; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--line-1); } @@ -495,32 +509,32 @@ min-width: 0; } +.psm-head-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: var(--sp-3); +} + .psm-identity-line { color: var(--brass); - display: block; - margin-bottom: var(--sp-2); + display: inline-flex; } .psm-heading-row { display: flex; flex-wrap: wrap; - align-items: baseline; - gap: var(--sp-4); -} - -.psm-symbol { - color: var(--fg-1); - font-family: var(--font-display); - font-size: clamp(3rem, 6vw, var(--fs-64)); - line-height: 0.95; - letter-spacing: -0.03em; + align-items: flex-start; + gap: var(--sp-3); } .psm-company-name { - color: var(--fg-2); + color: var(--fg-1); font-family: var(--font-display); - font-size: var(--fs-24); + font-size: clamp(2.8rem, 5vw, var(--fs-56)); font-style: italic; + line-height: 0.95; } .psm-partial-chip, @@ -559,22 +573,30 @@ color: var(--negative); } +.psm-tag { + border: 1px solid var(--line-2); + background: var(--ink-2); + color: var(--fg-2); +} + .psm-subline { margin-top: var(--sp-2); color: var(--fg-3); font-size: var(--fs-14); + max-width: 52ch; } .psm-price-stack { display: flex; flex-direction: column; align-items: flex-end; - gap: 4px; + gap: 5px; + padding-top: 2px; } .psm-price { color: var(--fg-1); - font-size: clamp(2.4rem, 4vw, var(--fs-48)); + font-size: clamp(2.6rem, 4vw, var(--fs-48)); line-height: 1; } @@ -585,6 +607,8 @@ .psm-quote-line { color: var(--fg-3); font-size: var(--fs-12); + text-transform: uppercase; + letter-spacing: var(--tr-wider); } .psm-primary-action, @@ -601,6 +625,7 @@ border: 1px solid var(--brass); background: var(--brass); color: var(--brass-ink); + margin-top: var(--sp-2); } .psm-primary-action.subtle { @@ -618,6 +643,7 @@ display: flex; flex-direction: column; gap: var(--sp-2); + padding-top: 8px; } .psm-range-values { @@ -629,6 +655,10 @@ font-size: var(--fs-12); } +.psm-range-spot { + color: var(--fg-1); +} + .psm-range-rail { position: relative; height: 4px; @@ -644,6 +674,11 @@ background: var(--brass); } +.psm-range-caption { + color: var(--fg-4); + font-size: var(--fs-12); +} + .psm-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); @@ -769,14 +804,12 @@ line-height: 1.5; } -.psm-signal-grid, .psm-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); } -.psm-signal, .psm-detail-item { border: 1px solid var(--line-1); border-radius: var(--r-2); @@ -792,7 +825,6 @@ text-transform: uppercase; } -.psm-signal-value, .psm-detail-value, .psm-stat-value { display: block; @@ -801,7 +833,6 @@ font-size: var(--fs-18); } -.psm-signal-copy, .psm-detail-copy { display: block; margin-top: 6px; @@ -810,20 +841,50 @@ line-height: 1.45; } -.psm-signal.pos { - border-color: rgba(79, 140, 94, 0.35); +.psm-signal-list { + display: flex; + flex-direction: column; } -.psm-signal.warn { - border-color: rgba(196, 149, 69, 0.35); +.psm-signal-row { + display: grid; + grid-template-columns: 84px minmax(0, 1fr) auto; + gap: var(--sp-3); + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--line-1); +} + +.psm-signal-row:last-child { + border-bottom: 0; +} + +.psm-signal-row.pos .psm-signal-value { + color: var(--positive); +} + +.psm-signal-row.warn .psm-signal-value { + color: var(--warning); +} + +.psm-signal-row.neg .psm-signal-value { + color: var(--negative); } -.psm-signal.neg { - border-color: rgba(181, 73, 75, 0.35); +.psm-signal-row.neu .psm-signal-value { + color: var(--fg-3); +} + +.psm-signal-value { + color: var(--fg-1); + font-family: var(--font-mono); + font-size: var(--fs-14); + font-variant-numeric: tabular-nums; } -.psm-signal.neu { - border-color: var(--line-2); +.psm-signal-copy { + color: var(--fg-2); + font-size: var(--fs-14); } .psm-profile-list, @@ -841,7 +902,7 @@ grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-3); align-items: start; - padding-bottom: var(--sp-3); + padding-bottom: var(--sp-2); border-bottom: 1px solid var(--line-1); } @@ -868,6 +929,11 @@ word-break: break-word; } +.psm-stat-value { + text-align: right; + white-space: nowrap; +} + .psm-source-value { font-family: var(--font-mono); font-size: var(--fs-12); @@ -884,6 +950,7 @@ display: flex; flex-wrap: wrap; gap: var(--sp-2); + margin-bottom: var(--sp-2); } .psm-field-tag { @@ -988,18 +1055,33 @@ .psm-kpis, .psm-main-grid, .psm-detail-grid, - .psm-signal-grid, .psm-ticker-head { grid-template-columns: 1fr; } .psm-heading-row { align-items: start; - flex-direction: column; - gap: var(--sp-2); } .psm-price-stack { align-items: start; } + + .psm-signal-row { + grid-template-columns: 1fr; + gap: 4px; + } + + .psm-watch-select { + grid-template-columns: minmax(0, 1fr) auto auto; + row-gap: 4px; + } + + .psm-watch-price { + grid-column: 2; + } + + .psm-watch-change { + grid-column: 3; + } } diff --git a/frontend/components/prism/ChartCard.tsx b/frontend/components/prism/ChartCard.tsx index bc650d7..87bb399 100644 --- a/frontend/components/prism/ChartCard.tsx +++ b/frontend/components/prism/ChartCard.tsx @@ -23,8 +23,8 @@ export function ChartCard({ symbol, period, points, chartState, chartError, onCh <section className="psm-card"> <div className="psm-card-head"> <div> - <div className="psm-eyebrow">Price History</div> - <h2 className="psm-card-title">{symbol}</h2> + <div className="psm-eyebrow">Price Action</div> + <h2 className="psm-card-title">{symbol} Price History</h2> </div> <div className="psm-tabs" role="tablist" aria-label="Chart range"> {PERIODS.map((option) => ( @@ -42,7 +42,7 @@ export function ChartCard({ symbol, period, points, chartState, chartError, onCh </div> </div> - <p className="psm-chart-meta">Chart loading is isolated from the rest of Overview. A history miss only affects this card.</p> + <p className="psm-chart-meta">Interactive history for the selected window. If history fails, the rest of Overview stays intact.</p> <div className="psm-chart-frame"> {chartState === "loading" ? <div className="psm-card-empty">Loading {period.toUpperCase()} history…</div> : null} @@ -52,4 +52,3 @@ export function ChartCard({ symbol, period, points, chartState, chartError, onCh </section> ); } - diff --git a/frontend/components/prism/Sidebar.tsx b/frontend/components/prism/Sidebar.tsx index 80e13f3..7f106d8 100644 --- a/frontend/components/prism/Sidebar.tsx +++ b/frontend/components/prism/Sidebar.tsx @@ -75,12 +75,12 @@ export function Sidebar({ return ( <div key={item.symbol} className={`psm-watch-row${active ? " active" : ""}`}> <button type="button" className="psm-watch-select" onClick={() => onSelectTicker(item.symbol)}> - <span className="psm-watch-main"> + <span className="psm-watch-cell psm-watch-main"> <span className="psm-watch-symbol">{item.symbol}</span> <span className="psm-watch-date">{watchlistSubtitle(item)}</span> </span> - <span className="psm-watch-price">{fmtCurrency(item.quote?.price)}</span> - <span className={`psm-watch-change ${deltaClass(item.quote?.change_pct)}`}>{fmtPct(item.quote?.change_pct, 2, true)}</span> + <span className="psm-watch-cell psm-watch-price">{fmtCurrency(item.quote?.price)}</span> + <span className={`psm-watch-cell psm-watch-change ${deltaClass(item.quote?.change_pct)}`}>{fmtPct(item.quote?.change_pct, 2, true)}</span> </button> <button type="button" diff --git a/frontend/components/prism/TickerHeader.tsx b/frontend/components/prism/TickerHeader.tsx index 23254f8..369d06c 100644 --- a/frontend/components/prism/TickerHeader.tsx +++ b/frontend/components/prism/TickerHeader.tsx @@ -10,15 +10,18 @@ type Props = { export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) { const pct = rangePercent(overview); + const lastSource = overview.meta.sources["quote.price"]; return ( <header className="psm-ticker-head"> <div className="psm-header-left"> - <span className="psm-identity-line psm-eyebrow">{overview.profile.symbol}</span> + <div className="psm-head-meta"> + <span className="psm-identity-line psm-eyebrow">{overview.profile.symbol}</span> + {overview.profile.exchange ? <span className="psm-tag">{overview.profile.exchange}</span> : null} + {overview.meta.is_partial ? <span className="psm-partial-chip">Partial Data</span> : null} + </div> <div className="psm-heading-row"> - <span className="psm-symbol">{overview.profile.symbol}</span> <span className="psm-company-name">{overview.profile.name || "Name unavailable"}</span> - {overview.meta.is_partial ? <span className="psm-partial-chip">Partial Data</span> : null} </div> <p className="psm-subline">{buildIdentityLine(overview)}</p> </div> @@ -27,12 +30,13 @@ export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) { <div className="psm-eyebrow">52 Week Range</div> <div className="psm-range-values"> <span>{fmtCurrency(overview.range_52w.low)}</span> - <span>{fmtCurrency(overview.range_52w.price ?? overview.quote.price)}</span> + <span className="psm-range-spot">{fmtCurrency(overview.range_52w.price ?? overview.quote.price)}</span> <span>{fmtCurrency(overview.range_52w.high)}</span> </div> <div className="psm-range-rail" aria-hidden> {pct != null ? <span className="psm-range-indicator" style={{ left: `${pct}%` }} /> : null} </div> + <div className="psm-range-caption">{pct == null ? "Range unavailable" : `${pct.toFixed(0)}% through the annual range`}</div> </div> <div className="psm-price-stack"> @@ -41,6 +45,7 @@ export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) { {fmtCurrency(overview.quote.change)} · {fmtPct(overview.quote.change_pct, 2, true)} </span> <span className="psm-quote-line">Prev close {fmtCurrency(overview.quote.prev_close)}</span> + {lastSource ? <span className="psm-quote-line">Source {lastSource.replaceAll("_", " ")}</span> : null} <button type="button" className={`psm-primary-action${isSaved ? " subtle" : ""}`} onClick={onToggleWatchlist}> {isSaved ? "Remove From Watchlist" : "Save To Watchlist"} </button> @@ -48,4 +53,3 @@ export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) { </header> ); } - diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts index efbd9f9..d02c5d3 100644 --- a/frontend/lib/overview.ts +++ b/frontend/lib/overview.ts @@ -41,14 +41,14 @@ export function buildKpis(overview: TickerOverview): KpiItem[] { { key: "P / E", value: overview.stats.trailing_pe == null ? "Unavailable" : `${fmtNumber(overview.stats.trailing_pe)}x`, - sublabel: `EPS ${fmtCurrency(overview.stats.trailing_eps)}`, + sublabel: `P/B ${overview.ratios.price_to_book == null ? "-" : `${fmtNumber(overview.ratios.price_to_book)}x`}`, missing: overview.stats.trailing_pe == null }, { - key: "Prev Close", - value: fmtCurrency(overview.quote.prev_close), - sublabel: `Day chg ${fmtCurrency(overview.quote.change)}`, - missing: overview.quote.prev_close == null + key: "EPS · TTM", + value: fmtCurrency(overview.stats.trailing_eps), + sublabel: `Net margin ${fmtPct(overview.ratios.net_margin_ttm)}`, + missing: overview.stats.trailing_eps == null }, { key: "52W Position", @@ -95,7 +95,7 @@ export function availableFieldSummary(overview: TickerOverview): string { const fields = Object.values(overview.meta.field_availability); if (!fields.length) return "Availability metadata unavailable"; const available = fields.filter(Boolean).length; - return `${available}/${fields.length} tracked fields available`; + return `${available} of ${fields.length} tracked fields filled`; } export function watchlistSubtitle(item: WatchlistItem): string { @@ -131,4 +131,3 @@ export function marketClock(now = new Date()) { }).format(now) }; } - diff --git a/frontend/types/api.ts b/frontend/types/api.ts index 679ada9..84dfd19 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -44,6 +44,22 @@ export type TickerOverview = { average_volume?: number | null; beta?: number | null; }; + ratios: { + price_to_book?: number | null; + price_to_sales?: number | null; + ev_to_sales?: number | null; + ev_to_ebitda?: number | null; + gross_margin_ttm?: number | null; + operating_margin_ttm?: number | null; + net_margin_ttm?: number | null; + roe_ttm?: number | null; + roa_ttm?: number | null; + roic_ttm?: number | null; + debt_to_equity?: number | null; + current_ratio?: number | null; + dividend_yield_ttm?: number | null; + dividend_payout_ratio_ttm?: number | null; + }; range_52w: { low?: number | null; high?: number | null; |
