From 257c25be1d5b6cba422ffe4f8bc2a31e76e1c68e Mon Sep 17 00:00:00 2001 From: Tyler Hoang 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 ; + } + + return ( + + ); +} + +function HeroSpark({ values, color }: { values: (number | null)[]; color: string }) { + const points = buildLine(values, 196, 52); + if (!points) { + return No trend; + } + + return ( + + ); +} + +function HeroCard({ + label, + point, + kind, + color, +}: { + label: string; + point: RatioPoint; + kind: ValueKind; + color: string; +}) { + return ( +
+
+ {label} + + Sector {formatValue(point.vs_sector, kind)} + +
+
+ {formatValue(point.value, kind)} +
+ +
+ ); +} + +function DetailRow({ + label, + point, + kind, + color, +}: { + label: string; + point: RatioPoint; + kind: ValueKind; + color: string; +}) { + return ( +
+ {label} + {formatValue(point.value, kind)} + {formatValue(point.vs_sector, kind)} + +
+ ); +} + +function GroupHeader({ label }: { label: string }) { + return ( +
+ {label} + Current + Sector + Trend +
+ ); +} + +export function RatiosCard({ data }: Props) { + const showDividends = data.dividend_yield.value != null || data.dividend_payout.value != null; + + return ( +
+
+
+
Key Ratios
+

Key Ratios

+
+
+ +
+ + + + +
+ +
+
+ + + + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + + +
+ + {showDividends && ( +
+ + + +
+ )} +
+
+ ); +} -- cgit v1.3-2-g0d8e