summaryrefslogtreecommitdiff
path: root/frontend/components/prism
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 22:45:59 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 22:45:59 -0700
commit66cfb26ebd8fa44b24e37b4ffc796ab29dcbd704 (patch)
tree4d98b268502c6aa7c8988957d6e41dffd319534d /frontend/components/prism
parent7fc2f0177518d70114aa75b7874a0ef59bdaec61 (diff)
parent52635efd7d435b091b4f13897511ca8e2c48f0b9 (diff)
Merge branch 'feat/key-ratios-tab'
Diffstat (limited to 'frontend/components/prism')
-rw-r--r--frontend/components/prism/FinancialsCard.tsx20
-rw-r--r--frontend/components/prism/FinancialsPage.tsx68
-rw-r--r--frontend/components/prism/RatiosCard.tsx212
-rw-r--r--frontend/components/prism/RatiosPage.tsx57
4 files changed, 319 insertions, 38 deletions
diff --git a/frontend/components/prism/FinancialsCard.tsx b/frontend/components/prism/FinancialsCard.tsx
index 94a6618..43a2dc2 100644
--- a/frontend/components/prism/FinancialsCard.tsx
+++ b/frontend/components/prism/FinancialsCard.tsx
@@ -9,16 +9,9 @@ type Props = {
data: FinancialsResponse;
statement: StatementKey;
period: PeriodKey;
- onChangeStatement: (s: StatementKey) => void;
onChangePeriod: (p: PeriodKey) => void;
};
-const STMT_LABELS: Record<StatementKey, string> = {
- income: "INCOME",
- balance: "BALANCE",
- cash_flow: "CASH FLOW",
-};
-
function fmtFinVal(val: number | null | undefined, isMargin: boolean): string {
if (val === null || val === undefined) return "—";
if (isMargin) return `${(val * 100).toFixed(1)}%`;
@@ -70,7 +63,6 @@ export function FinancialsCard({
data,
statement,
period,
- onChangeStatement,
onChangePeriod,
}: Props) {
const stmt = data[statement];
@@ -79,18 +71,6 @@ export function FinancialsCard({
return (
<section className="psm-card psm-financials-card">
<div className="psm-fin-header">
- <div className="psm-fin-tabs">
- {(["income", "balance", "cash_flow"] as StatementKey[]).map((key) => (
- <button
- key={key}
- type="button"
- className={`psm-fin-tab${statement === key ? " active" : ""}`}
- onClick={() => onChangeStatement(key)}
- >
- {STMT_LABELS[key]}
- </button>
- ))}
- </div>
<div className="psm-fin-period">
{(["annual", "quarterly"] as PeriodKey[]).map((p) => (
<button
diff --git a/frontend/components/prism/FinancialsPage.tsx b/frontend/components/prism/FinancialsPage.tsx
index fcd2763..9a56f2c 100644
--- a/frontend/components/prism/FinancialsPage.tsx
+++ b/frontend/components/prism/FinancialsPage.tsx
@@ -4,10 +4,12 @@ import { api } from "@/lib/api";
import { buildKpis } from "@/lib/overview";
import { FinancialsCard } from "@/components/prism/FinancialsCard";
import { KPIStrip } from "@/components/prism/KPIStrip";
+import { RatiosPage } from "@/components/prism/RatiosPage";
import { TickerHeader } from "@/components/prism/TickerHeader";
import type { FinancialsResponse, TickerOverview } from "@/types/api";
-type StatementKey = "income" | "balance" | "cash_flow";
+type StatementKey = "income" | "balance" | "cash_flow" | "ratios";
+type FinancialStatementKey = Exclude<StatementKey, "ratios">;
type PeriodKey = "annual" | "quarterly";
type FinState = "loading" | "ready" | "error";
@@ -18,6 +20,13 @@ type Props = {
onToggleWatchlist: () => void;
};
+const STATEMENT_LABELS: Record<StatementKey, string> = {
+ income: "INCOME",
+ balance: "BALANCE",
+ cash_flow: "CASH FLOW",
+ ratios: "RATIOS",
+};
+
export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) {
const [statement, setStatement] = useState<StatementKey>("income");
const [period, setPeriod] = useState<PeriodKey>("annual");
@@ -26,6 +35,10 @@ export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }:
const kpis = buildKpis(overview);
useEffect(() => {
+ if (statement === "ratios") {
+ return;
+ }
+
let cancelled = false;
setFinState("loading");
setData(null);
@@ -45,28 +58,47 @@ export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }:
return () => {
cancelled = true;
};
- }, [ticker, period]);
+ }, [ticker, period, statement]);
return (
<>
<TickerHeader overview={overview} isSaved={isSaved} onToggleWatchlist={onToggleWatchlist} />
<KPIStrip items={kpis} />
- {finState === "loading" && (
- <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} />
- )}
- {finState === "error" && (
- <section className="psm-card">
- <p className="psm-muted-copy">Financial statements unavailable for {ticker}.</p>
- </section>
- )}
- {finState === "ready" && data && (
- <FinancialsCard
- data={data}
- statement={statement}
- period={period}
- onChangeStatement={setStatement}
- onChangePeriod={setPeriod}
- />
+ <section className="psm-fin-tab-bar">
+ <div className="psm-fin-tabs">
+ {(["income", "balance", "cash_flow", "ratios"] as StatementKey[]).map((key) => (
+ <button
+ key={key}
+ type="button"
+ className={`psm-fin-tab${statement === key ? " active" : ""}`}
+ onClick={() => setStatement(key)}
+ >
+ {STATEMENT_LABELS[key]}
+ </button>
+ ))}
+ </div>
+ </section>
+ {statement === "ratios" ? (
+ <RatiosPage ticker={ticker} />
+ ) : (
+ <>
+ {finState === "loading" && (
+ <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} />
+ )}
+ {finState === "error" && (
+ <section className="psm-card">
+ <p className="psm-muted-copy">Financial statements unavailable for {ticker}.</p>
+ </section>
+ )}
+ {finState === "ready" && data && (
+ <FinancialsCard
+ data={data}
+ statement={statement as FinancialStatementKey}
+ period={period}
+ onChangePeriod={setPeriod}
+ />
+ )}
+ </>
)}
</>
);
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>
+ );
+}
diff --git a/frontend/components/prism/RatiosPage.tsx b/frontend/components/prism/RatiosPage.tsx
new file mode 100644
index 0000000..26868f8
--- /dev/null
+++ b/frontend/components/prism/RatiosPage.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api";
+import { RatiosCard } from "@/components/prism/RatiosCard";
+import type { RatiosResponse } from "@/types/api";
+
+type RatiosState = "loading" | "ready" | "error";
+
+type Props = {
+ ticker: string;
+};
+
+export function RatiosPage({ ticker }: Props) {
+ const [data, setData] = useState<RatiosResponse | null>(null);
+ const [ratiosState, setRatiosState] = useState<RatiosState>("loading");
+
+ useEffect(() => {
+ let cancelled = false;
+ setRatiosState("loading");
+ setData(null);
+
+ api
+ .ratios(ticker)
+ .then((res) => {
+ if (!cancelled) {
+ setData(res);
+ setRatiosState("ready");
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setRatiosState("error");
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ticker]);
+
+ if (ratiosState === "loading") {
+ return <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} />;
+ }
+
+ if (ratiosState === "error") {
+ return (
+ <section className="psm-card">
+ <p className="psm-muted-copy">Ratio data unavailable for {ticker}.</p>
+ </section>
+ );
+ }
+
+ if (ratiosState === "ready" && data) {
+ return <RatiosCard data={data} />;
+ }
+
+ return null;
+}