diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 22:45:59 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 22:45:59 -0700 |
| commit | 66cfb26ebd8fa44b24e37b4ffc796ab29dcbd704 (patch) | |
| tree | 4d98b268502c6aa7c8988957d6e41dffd319534d /frontend/components | |
| parent | 7fc2f0177518d70114aa75b7874a0ef59bdaec61 (diff) | |
| parent | 52635efd7d435b091b4f13897511ca8e2c48f0b9 (diff) | |
Merge branch 'feat/key-ratios-tab'
Diffstat (limited to 'frontend/components')
| -rw-r--r-- | frontend/components/prism/FinancialsCard.tsx | 20 | ||||
| -rw-r--r-- | frontend/components/prism/FinancialsPage.tsx | 68 | ||||
| -rw-r--r-- | frontend/components/prism/RatiosCard.tsx | 212 | ||||
| -rw-r--r-- | frontend/components/prism/RatiosPage.tsx | 57 |
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; +} |
