summaryrefslogtreecommitdiff
path: root/frontend
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
parent147664128fa0281333ba3150e007ed8e2f6a616a (diff)
parent1e349b8904c6fa52c6f0925453513354c1a4e392 (diff)
Merge worktree-financials-tab: add financials tab (income/balance/cashflow)
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/page.tsx63
-rw-r--r--frontend/app/prism-shell.css184
-rw-r--r--frontend/components/prism/FinancialsCard.tsx136
-rw-r--r--frontend/components/prism/FinancialsPage.tsx73
-rw-r--r--frontend/components/prism/Sidebar.tsx5
-rw-r--r--frontend/lib/api.ts7
-rw-r--r--frontend/lib/overview.ts2
-rw-r--r--frontend/types/api.ts21
8 files changed, 470 insertions, 21 deletions
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 44428aa..3bec411 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -4,6 +4,7 @@ import { FormEvent, Suspense, startTransition, useCallback, useEffect, useMemo,
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { AppShell } from "@/components/prism/AppShell";
+import { FinancialsPage } from "@/components/prism/FinancialsPage";
import { ChartCard } from "@/components/prism/ChartCard";
import { KPIStrip } from "@/components/prism/KPIStrip";
import { Sidebar } from "@/components/prism/Sidebar";
@@ -29,6 +30,7 @@ function OverviewClient() {
const router = useRouter();
const searchParams = useSearchParams();
const selectedTicker = (searchParams.get("ticker") || "").toUpperCase();
+ const tab = searchParams.get("tab") || "overview";
const lastTickerRef = useRef("");
const [query, setQuery] = useState("");
@@ -72,11 +74,26 @@ function OverviewClient() {
setOverviewState("loading");
setChartState("loading");
+ const params = new URLSearchParams();
+ params.set("ticker", normalized);
+ if (tab !== "overview") params.set("tab", tab);
startTransition(() => {
- router.push(`/?ticker=${encodeURIComponent(normalized)}`);
+ router.push(`/?${params.toString()}`);
});
},
- [router]
+ [router, tab]
+ );
+
+ const navigateToTab = useCallback(
+ (key: string) => {
+ const params = new URLSearchParams();
+ if (selectedTicker) params.set("ticker", selectedTicker);
+ if (key !== "overview") params.set("tab", key);
+ startTransition(() => {
+ router.push(`/?${params.toString()}`);
+ });
+ },
+ [router, selectedTicker]
);
const clearTicker = useCallback(() => {
@@ -245,12 +262,13 @@ function OverviewClient() {
sidebar={
<Sidebar
navItems={OVERVIEW_NAV_ITEMS}
- selectedKey="overview"
+ selectedKey={tab}
currentTicker={selectedTicker}
watchlist={watchlist}
watchlistError={watchlistError}
onSelectTicker={navigateToTicker}
onRemoveTicker={removeFromWatchlist}
+ onSelectTab={navigateToTab}
/>
}
topbar={
@@ -271,22 +289,31 @@ function OverviewClient() {
{selectedTicker && overviewState === "invalid" ? <InvalidTickerState symbol={selectedTicker} onClear={clearTicker} /> : null}
{selectedTicker && overviewState === "error" ? <ErrorOverviewState message={overviewError || "Ticker data unavailable"} /> : null}
{overview && overviewState === "ready" ? (
- <>
- <TickerHeader overview={overview} onToggleWatchlist={addOrRemoveCurrentTicker} isSaved={isSaved} />
- <KPIStrip items={kpis} />
- <div className="psm-main-grid">
- <div className="psm-column">
- <ChartCard symbol={overview.profile.symbol} period={period} points={history} chartState={chartState} chartError={chartError} onChangePeriod={setPeriod} />
- <SignalCard overview={overview} />
- </div>
- <div className="psm-column">
- <DataStatusCard overview={overview} missingFields={missingFields} />
- <ProfileCard overview={overview} />
- <ShortInterestCard overview={overview} />
- <StatsCard overview={overview} />
+ tab === "financials" ? (
+ <FinancialsPage
+ ticker={selectedTicker}
+ overview={overview}
+ isSaved={isSaved}
+ onToggleWatchlist={addOrRemoveCurrentTicker}
+ />
+ ) : (
+ <>
+ <TickerHeader overview={overview} onToggleWatchlist={addOrRemoveCurrentTicker} isSaved={isSaved} />
+ <KPIStrip items={kpis} />
+ <div className="psm-main-grid">
+ <div className="psm-column">
+ <ChartCard symbol={overview.profile.symbol} period={period} points={history} chartState={chartState} chartError={chartError} onChangePeriod={setPeriod} />
+ <SignalCard overview={overview} />
+ </div>
+ <div className="psm-column">
+ <DataStatusCard overview={overview} missingFields={missingFields} />
+ <ProfileCard overview={overview} />
+ <ShortInterestCard overview={overview} />
+ <StatsCard overview={overview} />
+ </div>
</div>
- </div>
- </>
+ </>
+ )
) : null}
</AppShell>
);
diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css
index 6ccf9ca..cd0023a 100644
--- a/frontend/app/prism-shell.css
+++ b/frontend/app/prism-shell.css
@@ -1105,3 +1105,187 @@
grid-column: 3;
}
}
+
+/* ── Financials Card ─────────────────────────────── */
+
+.psm-financials-card {
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 480px;
+ overflow: hidden;
+}
+
+.psm-fin-header {
+ display: flex;
+ align-items: stretch;
+ border-bottom: 1px solid var(--line-1);
+ padding: 0 var(--sp-4);
+ flex-shrink: 0;
+}
+
+.psm-fin-tabs {
+ display: flex;
+ margin-right: auto;
+}
+
+.psm-fin-tab {
+ padding: var(--sp-3) var(--sp-3);
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--fg-4);
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ letter-spacing: 0.04em;
+ cursor: pointer;
+ transition: color 150ms ease;
+ margin-bottom: -1px;
+}
+
+.psm-fin-tab:hover {
+ color: var(--fg-2);
+}
+
+.psm-fin-tab.active {
+ border-bottom-color: var(--brass);
+ color: var(--brass);
+}
+
+.psm-fin-period {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-1);
+}
+
+.psm-fin-period-btn {
+ padding: 3px var(--sp-2);
+ background: none;
+ border: 1px solid var(--line-1);
+ border-radius: var(--r-1);
+ color: var(--fg-4);
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ letter-spacing: 0.04em;
+ cursor: pointer;
+ transition: all 150ms ease;
+}
+
+.psm-fin-period-btn:hover {
+ color: var(--fg-2);
+ border-color: var(--line-2);
+}
+
+.psm-fin-period-btn.active {
+ background: rgba(194, 170, 122, 0.1);
+ border-color: rgba(194, 170, 122, 0.3);
+ color: var(--brass);
+}
+
+.psm-fin-table-wrap {
+ overflow: auto;
+ flex: 1;
+}
+
+.psm-fin-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--fs-13);
+}
+
+.psm-fin-table thead tr {
+ border-bottom: 1px solid var(--line-1);
+}
+
+.psm-fin-label-col {
+ text-align: left;
+ padding: var(--sp-2) var(--sp-4);
+ color: var(--fg-4);
+ font-family: var(--font-sans);
+ font-weight: 400;
+ min-width: 180px;
+}
+
+.psm-fin-val-col {
+ text-align: right;
+ padding: var(--sp-2) var(--sp-3);
+ color: var(--fg-4);
+ font-family: var(--font-mono);
+ font-weight: 400;
+ white-space: nowrap;
+}
+
+.psm-fin-val-col.accent {
+ color: var(--brass);
+}
+
+.psm-fin-section-row td {
+ padding: var(--sp-3) var(--sp-4) var(--sp-1);
+}
+
+.psm-fin-section-label {
+ color: var(--fg-4);
+ font-family: var(--font-sans);
+ font-size: var(--fs-12);
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.psm-fin-row td {
+ border-bottom: 1px solid var(--ink-2);
+}
+
+.psm-fin-row.is-total td {
+ border-bottom-color: var(--line-1);
+}
+
+.psm-fin-label {
+ padding: var(--sp-2) var(--sp-4);
+ color: var(--fg-3);
+ font-family: var(--font-sans);
+ white-space: nowrap;
+}
+
+.psm-fin-row.is-indent .psm-fin-label {
+ padding-left: calc(var(--sp-4) + 12px);
+}
+
+.psm-fin-row.is-total .psm-fin-label {
+ color: var(--fg-1);
+ font-weight: 500;
+}
+
+.psm-fin-row.is-margin .psm-fin-label {
+ font-style: italic;
+ color: var(--fg-4);
+ font-size: var(--fs-12);
+}
+
+.psm-fin-val {
+ text-align: right;
+ padding: var(--sp-2) var(--sp-3);
+ color: var(--fg-2);
+ font-family: var(--font-mono);
+ white-space: nowrap;
+}
+
+.psm-fin-val.accent {
+ color: var(--brass);
+}
+
+.psm-fin-val.neg {
+ color: var(--negative);
+}
+
+.psm-fin-row.is-total .psm-fin-val {
+ color: var(--fg-1);
+}
+
+.psm-fin-row.is-margin .psm-fin-val {
+ color: var(--fg-4);
+ font-size: var(--fs-12);
+}
+
+.psm-fin-empty {
+ padding: var(--sp-4);
+}
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">
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index b2f0dea..05af5ae 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -1,4 +1,4 @@
-import type { HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse } from "@/types/api";
+import type { FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse } from "@/types/api";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
@@ -48,5 +48,10 @@ export const api = {
},
removeWatchlist(symbol: string) {
return request<WatchlistResponse>(`/api/watchlist/${encodeURIComponent(symbol)}`, { method: "DELETE" });
+ },
+ financials(symbol: string, period: "annual" | "quarterly" = "annual") {
+ return request<FinancialsResponse>(
+ `/api/tickers/${encodeURIComponent(symbol)}/financials?period=${encodeURIComponent(period)}`
+ );
}
};
diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts
index d02c5d3..1ec793a 100644
--- a/frontend/lib/overview.ts
+++ b/frontend/lib/overview.ts
@@ -17,7 +17,7 @@ export type NavItem = {
export const OVERVIEW_NAV_ITEMS: NavItem[] = [
{ key: "overview", label: "Overview", icon: "chart" },
- { key: "financials", label: "Financials", icon: "ledger", disabled: true },
+ { key: "financials", label: "Financials", icon: "ledger" },
{ key: "valuation", label: "Valuation", icon: "dollar", disabled: true },
{ key: "options", label: "Options", icon: "window", disabled: true },
{ key: "insiders", label: "Insiders", icon: "pulse", disabled: true },
diff --git a/frontend/types/api.ts b/frontend/types/api.ts
index 84dfd19..3cc93e4 100644
--- a/frontend/types/api.ts
+++ b/frontend/types/api.ts
@@ -99,3 +99,24 @@ export type WatchlistResponse = {
items: WatchlistItem[];
limit: number;
};
+
+export type FinancialRow = {
+ label: string;
+ indent: number;
+ is_total: boolean;
+ is_section: boolean;
+ is_margin: boolean;
+ values: (number | null)[];
+};
+
+export type FinancialStatement = {
+ columns: string[];
+ rows: FinancialRow[];
+};
+
+export type FinancialsResponse = {
+ period: "annual" | "quarterly";
+ income: FinancialStatement;
+ balance: FinancialStatement;
+ cash_flow: FinancialStatement;
+};