From 257c25be1d5b6cba422ffe4f8bc2a31e76e1c68e Mon Sep 17 00:00:00 2001
From: Tyler Hoang <tyler@tylerhoang.xyz>
Date: Mon, 18 May 2026 22:14:42 -0700
Subject: feat: add RatiosCard component with hero KPIs, sparklines, and detail
 rows

---
 frontend/components/prism/RatiosCard.tsx | 212 +++++++++++++++++++++++++++++++
 1 file changed, 212 insertions(+)
 create mode 100644 frontend/components/prism/RatiosCard.tsx

(limited to 'frontend/components/prism/RatiosCard.tsx')

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>
+  );
+}
-- 
cgit v1.3-2-g0d8e

