summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 22:14:42 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 22:14:42 -0700
commit257c25be1d5b6cba422ffe4f8bc2a31e76e1c68e (patch)
tree1f62d6b0a6b0000fd226a3bc30d9cca89ce1b03b
parent7a2cea0a26001d8a7b94423606b650d878df8162 (diff)
feat: add RatiosCard component with hero KPIs, sparklines, and detail rows
-rw-r--r--frontend/app/prism-shell.css187
-rw-r--r--frontend/components/prism/RatiosCard.tsx212
2 files changed, 399 insertions, 0 deletions
diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css
index 9a37bdd..424ebb3 100644
--- a/frontend/app/prism-shell.css
+++ b/frontend/app/prism-shell.css
@@ -1490,3 +1490,190 @@
font-variant-numeric: tabular-nums;
text-align: right;
}
+
+/* ── Key ratios tab ─────────────────────────────── */
+
+.psm-ratio-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-5);
+}
+
+.psm-ratio-heroes {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--sp-3);
+}
+
+.psm-ratio-hero {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-3);
+ min-width: 0;
+ padding: var(--sp-4);
+ background: var(--ink-2);
+ border: 1px solid var(--line-1);
+ border-radius: var(--r-2);
+}
+
+.psm-ratio-hero-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--sp-3);
+}
+
+.psm-ratio-hero-label {
+ color: var(--fg-3);
+ font-size: var(--fs-12);
+ font-weight: 600;
+ letter-spacing: var(--tr-wider);
+ text-transform: uppercase;
+}
+
+.psm-ratio-hero-sector {
+ color: var(--fg-4);
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.psm-ratio-hero-value {
+ font-family: var(--font-mono);
+ font-size: var(--fs-32);
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-ratio-hero-spark {
+ width: 100%;
+ height: 52px;
+}
+
+.psm-ratio-detail {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-5);
+}
+
+.psm-ratio-group-label {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 96px 96px 88px;
+ gap: var(--sp-3);
+ align-items: center;
+ padding-bottom: var(--sp-2);
+ border-bottom: 1px solid var(--line-1);
+ color: var(--fg-4);
+ font-size: var(--fs-12);
+ font-weight: 600;
+ letter-spacing: var(--tr-wider);
+ text-transform: uppercase;
+}
+
+.psm-ratio-group-label span:nth-child(2),
+.psm-ratio-group-label span:nth-child(3) {
+ text-align: right;
+}
+
+.psm-ratio-group-label span:last-child {
+ text-align: center;
+}
+
+.psm-ratio-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 96px 96px 88px;
+ gap: var(--sp-3);
+ align-items: center;
+ padding: var(--sp-3) 0;
+ border-bottom: 1px solid var(--ink-2);
+}
+
+.psm-ratio-row-label {
+ color: var(--fg-2);
+ font-size: var(--fs-13);
+}
+
+.psm-ratio-row-value,
+.psm-ratio-row-sector {
+ color: var(--fg-1);
+ font-family: var(--font-mono);
+ font-size: var(--fs-13);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.psm-ratio-row-sector {
+ color: var(--fg-4);
+}
+
+.psm-ratio-mini-spark {
+ width: 88px;
+ height: 24px;
+}
+
+.psm-ratio-mini-spark,
+.psm-ratio-hero-spark {
+ display: block;
+}
+
+.psm-ratio-spark-empty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 88px;
+ height: 24px;
+ color: var(--fg-4);
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-ratio-hero .psm-ratio-spark-empty {
+ width: 100%;
+ height: 52px;
+ justify-content: flex-start;
+}
+
+@media (max-width: 980px) {
+ .psm-ratio-group-label,
+ .psm-ratio-row {
+ grid-template-columns: minmax(0, 1fr) 88px 88px 72px;
+ }
+
+ .psm-ratio-mini-spark,
+ .psm-ratio-spark-empty {
+ width: 72px;
+ }
+}
+
+@media (max-width: 680px) {
+ .psm-ratio-heroes {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .psm-ratio-group-label {
+ grid-template-columns: minmax(0, 1fr) 84px 84px 64px;
+ gap: var(--sp-2);
+ }
+
+ .psm-ratio-row {
+ grid-template-columns: minmax(0, 1fr) 84px 84px 64px;
+ gap: var(--sp-2);
+ }
+
+ .psm-ratio-hero-head {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .psm-ratio-hero-sector {
+ text-align: left;
+ }
+
+ .psm-ratio-mini-spark,
+ .psm-ratio-spark-empty {
+ width: 64px;
+ }
+}
diff --git a/frontend/components/prism/RatiosCard.tsx b/frontend/components/prism/RatiosCard.tsx
new file mode 100644
index 0000000..1a00829
--- /dev/null
+++ b/frontend/components/prism/RatiosCard.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import type { RatioPoint, RatiosResponse } from "@/types/api";
+import { fmtNumber, fmtPct } from "@/lib/format";
+
+const BRASS = "#C2AA7A";
+const GAIN = "#4F8C5E";
+
+type Props = {
+ data: RatiosResponse;
+};
+
+type ValueKind = "multiple" | "percent" | "coverage";
+
+function buildLine(values: (number | null)[], width: number, height: number): string {
+ const numeric = values.filter((value): value is number => value != null && Number.isFinite(value));
+ if (numeric.length === 0) return "";
+ if (numeric.length === 1) {
+ const y = height / 2;
+ return `0,${y} ${width},${y}`;
+ }
+
+ const min = Math.min(...numeric);
+ const max = Math.max(...numeric);
+ const range = max - min || 1;
+
+ return numeric
+ .map((value, index) => {
+ const x = (index / (numeric.length - 1)) * width;
+ const y = max === min ? height / 2 : height - ((value - min) / range) * height;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ })
+ .join(" ");
+}
+
+function fmtMultiple(value?: number | null): string {
+ if (value == null || Number.isNaN(value)) return "—";
+ return `${fmtNumber(value)}x`;
+}
+
+function fmtCoverage(value?: number | null): string {
+ if (value == null || Number.isNaN(value)) return "—";
+ return `${fmtNumber(value)}x`;
+}
+
+function formatValue(value: number | null, kind: ValueKind): string {
+ if (kind === "percent") return fmtPct(value);
+ if (kind === "coverage") return fmtCoverage(value);
+ return fmtMultiple(value);
+}
+
+function MiniSpark({ values, color }: { values: (number | null)[]; color: string }) {
+ const points = buildLine(values, 88, 24);
+ if (!points) {
+ return <span className="psm-ratio-spark-empty">—</span>;
+ }
+
+ return (
+ <svg className="psm-ratio-mini-spark" viewBox="0 0 88 24" aria-hidden="true">
+ <polyline
+ points={points}
+ fill="none"
+ stroke={color}
+ strokeWidth="1.8"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ );
+}
+
+function HeroSpark({ values, color }: { values: (number | null)[]; color: string }) {
+ const points = buildLine(values, 196, 52);
+ if (!points) {
+ return <span className="psm-ratio-spark-empty">No trend</span>;
+ }
+
+ return (
+ <svg className="psm-ratio-hero-spark" viewBox="0 0 196 52" aria-hidden="true">
+ <polyline
+ points={points}
+ fill="none"
+ stroke={color}
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ );
+}
+
+function HeroCard({
+ label,
+ point,
+ kind,
+ color,
+}: {
+ label: string;
+ point: RatioPoint;
+ kind: ValueKind;
+ color: string;
+}) {
+ return (
+ <article className="psm-ratio-hero">
+ <div className="psm-ratio-hero-head">
+ <span className="psm-ratio-hero-label">{label}</span>
+ <span className="psm-ratio-hero-sector">
+ Sector {formatValue(point.vs_sector, kind)}
+ </span>
+ </div>
+ <div className="psm-ratio-hero-value" style={{ color }}>
+ {formatValue(point.value, kind)}
+ </div>
+ <HeroSpark values={point.spark} color={color} />
+ </article>
+ );
+}
+
+function DetailRow({
+ label,
+ point,
+ kind,
+ color,
+}: {
+ label: string;
+ point: RatioPoint;
+ kind: ValueKind;
+ color: string;
+}) {
+ return (
+ <div className="psm-ratio-row">
+ <span className="psm-ratio-row-label">{label}</span>
+ <span className="psm-ratio-row-value">{formatValue(point.value, kind)}</span>
+ <span className="psm-ratio-row-sector">{formatValue(point.vs_sector, kind)}</span>
+ <MiniSpark values={point.spark} color={color} />
+ </div>
+ );
+}
+
+function GroupHeader({ label }: { label: string }) {
+ return (
+ <div className="psm-ratio-group-label">
+ <span>{label}</span>
+ <span>Current</span>
+ <span>Sector</span>
+ <span>Trend</span>
+ </div>
+ );
+}
+
+export function RatiosCard({ data }: Props) {
+ const showDividends = data.dividend_yield.value != null || data.dividend_payout.value != null;
+
+ return (
+ <section className="psm-card psm-ratio-card">
+ <div className="psm-card-head">
+ <div>
+ <div className="psm-eyebrow">Key Ratios</div>
+ <h2 className="psm-card-title">Key Ratios</h2>
+ </div>
+ </div>
+
+ <div className="psm-ratio-heroes">
+ <HeroCard label="P / E TTM" point={data.pe_ttm} kind="multiple" color={BRASS} />
+ <HeroCard label="EV / EBITDA" point={data.ev_ebitda} kind="multiple" color={BRASS} />
+ <HeroCard label="Gross Margin" point={data.gross_margin} kind="percent" color={GAIN} />
+ <HeroCard label="Net Margin" point={data.net_margin} kind="percent" color={GAIN} />
+ </div>
+
+ <div className="psm-ratio-detail">
+ <section>
+ <GroupHeader label="Valuation" />
+ <DetailRow label="P / Book" point={data.price_to_book} kind="multiple" color={BRASS} />
+ <DetailRow label="P / Sales" point={data.price_to_sales} kind="multiple" color={BRASS} />
+ <DetailRow label="EV / Sales" point={data.ev_to_sales} kind="multiple" color={BRASS} />
+ <DetailRow label="P / FCF" point={data.p_fcf} kind="multiple" color={BRASS} />
+ <DetailRow label="Forward P / E" point={data.forward_pe} kind="multiple" color={BRASS} />
+ </section>
+
+ <section>
+ <GroupHeader label="Profitability" />
+ <DetailRow label="Operating Margin" point={data.operating_margin} kind="percent" color={GAIN} />
+ <DetailRow label="EBITDA Margin" point={data.ebitda_margin} kind="percent" color={GAIN} />
+ <DetailRow label="FCF Margin" point={data.fcf_margin} kind="percent" color={GAIN} />
+ </section>
+
+ <section>
+ <GroupHeader label="Returns" />
+ <DetailRow label="ROE" point={data.roe} kind="percent" color={GAIN} />
+ <DetailRow label="ROA" point={data.roa} kind="percent" color={GAIN} />
+ <DetailRow label="ROIC" point={data.roic} kind="percent" color={GAIN} />
+ </section>
+
+ <section>
+ <GroupHeader label="Leverage / Liquidity" />
+ <DetailRow label="Debt / Equity" point={data.debt_to_equity} kind="multiple" color={BRASS} />
+ <DetailRow label="Current Ratio" point={data.current_ratio} kind="multiple" color={BRASS} />
+ <DetailRow label="Quick Ratio" point={data.quick_ratio} kind="multiple" color={BRASS} />
+ <DetailRow label="Interest Coverage" point={data.interest_coverage} kind="coverage" color={BRASS} />
+ </section>
+
+ {showDividends && (
+ <section>
+ <GroupHeader label="Dividends" />
+ <DetailRow label="Dividend Yield" point={data.dividend_yield} kind="percent" color={GAIN} />
+ <DetailRow label="Payout Ratio" point={data.dividend_payout} kind="percent" color={GAIN} />
+ </section>
+ )}
+ </div>
+ </section>
+ );
+}