summaryrefslogtreecommitdiff
path: root/frontend/components/prism
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 00:46:27 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 00:46:27 -0700
commit45f3eff514a7b4c2f03e801b204920f13671dd5d (patch)
treea71e4cd06a4af8357003c9942760e019f52414dc /frontend/components/prism
parent147664128fa0281333ba3150e007ed8e2f6a616a (diff)
parent1e349b8904c6fa52c6f0925453513354c1a4e392 (diff)
Merge worktree-financials-tab: add financials tab (income/balance/cashflow)
Diffstat (limited to 'frontend/components/prism')
-rw-r--r--frontend/components/prism/FinancialsCard.tsx136
-rw-r--r--frontend/components/prism/FinancialsPage.tsx73
-rw-r--r--frontend/components/prism/Sidebar.tsx5
3 files changed, 213 insertions, 1 deletions
diff --git a/frontend/components/prism/FinancialsCard.tsx b/frontend/components/prism/FinancialsCard.tsx
new file mode 100644
index 0000000..94a6618
--- /dev/null
+++ b/frontend/components/prism/FinancialsCard.tsx
@@ -0,0 +1,136 @@
+"use client";
+import type { FinancialRow, FinancialsResponse } from "@/types/api";
+import { fmtLarge } from "@/lib/format";
+
+type StatementKey = "income" | "balance" | "cash_flow";
+type PeriodKey = "annual" | "quarterly";
+
+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)}%`;
+ return fmtLarge(val);
+}
+
+function FinRow({ row, lastColIdx }: { row: FinancialRow; lastColIdx: number }) {
+ if (row.is_section) {
+ return (
+ <tr className="psm-fin-section-row">
+ <td className="psm-fin-section-label" colSpan={lastColIdx + 2}>
+ {row.label}
+ </td>
+ </tr>
+ );
+ }
+
+ const cls = [
+ "psm-fin-row",
+ row.is_total ? "is-total" : "",
+ row.is_margin ? "is-margin" : "",
+ row.indent === 1 ? "is-indent" : "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+ <tr className={cls}>
+ <td className="psm-fin-label">{row.label}</td>
+ {row.values.map((val, i) => (
+ <td
+ key={i}
+ className={[
+ "psm-fin-val",
+ i === lastColIdx ? "accent" : "",
+ val !== null && val < 0 && !row.is_margin ? "neg" : "",
+ ]
+ .filter(Boolean)
+ .join(" ")}
+ >
+ {fmtFinVal(val, row.is_margin)}
+ </td>
+ ))}
+ </tr>
+ );
+}
+
+export function FinancialsCard({
+ data,
+ statement,
+ period,
+ onChangeStatement,
+ onChangePeriod,
+}: Props) {
+ const stmt = data[statement];
+ const lastColIdx = stmt.columns.length - 1;
+
+ 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
+ key={p}
+ type="button"
+ className={`psm-fin-period-btn${period === p ? " active" : ""}`}
+ onClick={() => onChangePeriod(p)}
+ >
+ {p === "annual" ? "ANNUAL" : "QUARTERLY"}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {stmt.columns.length === 0 ? (
+ <p className="psm-muted-copy psm-fin-empty">Statement data unavailable.</p>
+ ) : (
+ <div className="psm-fin-table-wrap">
+ <table className="psm-fin-table">
+ <thead>
+ <tr>
+ <th className="psm-fin-label-col">USD (millions)</th>
+ {stmt.columns.map((col, i) => (
+ <th
+ key={i}
+ className={`psm-fin-val-col${i === lastColIdx ? " accent" : ""}`}
+ >
+ {col}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {stmt.rows.map((row, i) => (
+ <FinRow key={i} row={row} lastColIdx={lastColIdx} />
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </section>
+ );
+}
diff --git a/frontend/components/prism/FinancialsPage.tsx b/frontend/components/prism/FinancialsPage.tsx
new file mode 100644
index 0000000..fcd2763
--- /dev/null
+++ b/frontend/components/prism/FinancialsPage.tsx
@@ -0,0 +1,73 @@
+"use client";
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api";
+import { buildKpis } from "@/lib/overview";
+import { FinancialsCard } from "@/components/prism/FinancialsCard";
+import { KPIStrip } from "@/components/prism/KPIStrip";
+import { TickerHeader } from "@/components/prism/TickerHeader";
+import type { FinancialsResponse, TickerOverview } from "@/types/api";
+
+type StatementKey = "income" | "balance" | "cash_flow";
+type PeriodKey = "annual" | "quarterly";
+type FinState = "loading" | "ready" | "error";
+
+type Props = {
+ ticker: string;
+ overview: TickerOverview;
+ isSaved: boolean;
+ onToggleWatchlist: () => void;
+};
+
+export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) {
+ const [statement, setStatement] = useState<StatementKey>("income");
+ const [period, setPeriod] = useState<PeriodKey>("annual");
+ const [data, setData] = useState<FinancialsResponse | null>(null);
+ const [finState, setFinState] = useState<FinState>("loading");
+ const kpis = buildKpis(overview);
+
+ useEffect(() => {
+ let cancelled = false;
+ setFinState("loading");
+ setData(null);
+
+ api
+ .financials(ticker, period)
+ .then((res) => {
+ if (!cancelled) {
+ setData(res);
+ setFinState("ready");
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setFinState("error");
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ticker, period]);
+
+ 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}
+ />
+ )}
+ </>
+ );
+}
diff --git a/frontend/components/prism/Sidebar.tsx b/frontend/components/prism/Sidebar.tsx
index 7f106d8..fb8ebcf 100644
--- a/frontend/components/prism/Sidebar.tsx
+++ b/frontend/components/prism/Sidebar.tsx
@@ -12,6 +12,7 @@ type Props = {
watchlistError: string | null;
onSelectTicker: (symbol: string) => void;
onRemoveTicker: (symbol: string) => void;
+ onSelectTab: (key: string) => void;
};
export function Sidebar({
@@ -21,7 +22,8 @@ export function Sidebar({
watchlist,
watchlistError,
onSelectTicker,
- onRemoveTicker
+ onRemoveTicker,
+ onSelectTab
}: Props) {
return (
<aside className="psm-side">
@@ -46,6 +48,7 @@ export function Sidebar({
type="button"
className={`psm-nav-item${active ? " active" : ""}${item.disabled ? " disabled" : ""}`}
disabled={item.disabled}
+ onClick={item.disabled ? undefined : () => onSelectTab(item.key)}
>
<span className={`psm-icon icon-${item.icon}`} aria-hidden />
<span className="psm-nav-copy">