# Financials Tab Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a Financials tab to Prism v2 that surfaces Income Statement, Balance Sheet, and Cash Flow for any ticker, with Annual (4 years + TTM/MRQ) and Quarterly (8 quarters) period toggle. **Architecture:** New backend endpoint `/api/tickers/{symbol}/financials?period=annual|quarterly` builds structured row/column data from yfinance statements. Frontend adds `FinancialsCard` (the table component) and `FinancialsPage` (data-fetching wrapper) rendered when `?tab=financials` is present in the URL. Sidebar nav item wired up via new `onSelectTab` prop. **Tech Stack:** Python/FastAPI/yfinance/pydantic (backend); Next.js/TypeScript/React (frontend); cachetools TTLCache at 1 hour for financials. --- ## File Map **Create:** - `frontend/components/prism/FinancialsCard.tsx` - `frontend/components/prism/FinancialsPage.tsx` **Modify:** - `backend/app/schemas.py` — add FinancialRow, FinancialStatement, FinancialsResponse - `backend/app/services/data_service.py` — add FINANCIALS_CACHE, helper fns, get_financials() - `backend/app/main.py` — add /financials route - `backend/tests/test_api.py` — add tests for get_financials + route - `frontend/types/api.ts` — add FinancialRow, FinancialStatement, FinancialsResponse - `frontend/lib/api.ts` — add api.financials() - `frontend/lib/overview.ts` — enable financials nav item (remove disabled) - `frontend/app/prism-shell.css` — add financials card CSS - `frontend/components/prism/Sidebar.tsx` — add onSelectTab prop - `frontend/app/page.tsx` — read tab param, render FinancialsPage, pass onSelectTab --- ## Task 1: Backend Schemas **Files:** - Modify: `backend/app/schemas.py` - [ ] **Step 1: Write the failing import test** Add to `backend/tests/test_api.py` (after existing imports): ```python def test_financials_schema_structure() -> None: from app.schemas import FinancialRow, FinancialStatement, FinancialsResponse row = FinancialRow(label="Revenue", indent=0, is_total=True, values=[1.0, 2.0, None]) assert row.label == "Revenue" assert row.is_total is True assert row.values[2] is None stmt = FinancialStatement(columns=["FY 2024", "TTM"], rows=[row]) assert len(stmt.columns) == 2 resp = FinancialsResponse( period="annual", income=stmt, balance=FinancialStatement(columns=[], rows=[]), cash_flow=FinancialStatement(columns=[], rows=[]), ) assert resp.period == "annual" ``` - [ ] **Step 2: Run test to confirm it fails** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_financials_schema_structure -v ``` Expected: `ImportError` — FinancialRow not defined yet. - [ ] **Step 3: Add schemas to `backend/app/schemas.py`** Add after the `HistoryPoint` class: ```python class FinancialRow(BaseModel): label: str indent: int = 0 is_total: bool = False is_section: bool = False is_margin: bool = False values: list[float | None] = Field(default_factory=list) class FinancialStatement(BaseModel): columns: list[str] = Field(default_factory=list) rows: list[FinancialRow] = Field(default_factory=list) class FinancialsResponse(BaseModel): period: Literal["annual", "quarterly"] income: FinancialStatement balance: FinancialStatement cash_flow: FinancialStatement ``` - [ ] **Step 4: Run test to confirm it passes** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_financials_schema_structure -v ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add backend/app/schemas.py backend/tests/test_api.py git commit -m "feat: add FinancialRow/Statement/Response schemas" ``` --- ## Task 2: Backend Data Service — Helpers **Files:** - Modify: `backend/app/services/data_service.py` - [ ] **Step 1: Add FINANCIALS_CACHE and helper functions** In `data_service.py`, add after the existing cache declarations (after `RATIO_CACHE`): ```python FINANCIALS_CACHE: TTLCache = TTLCache(maxsize=128, ttl=3600) ``` Then add these helper functions after the existing `_cap_ratio` function: ```python def _fmt_col(ts: Any, quarterly: bool) -> str: t = pd.Timestamp(ts) if quarterly: q = (t.month - 1) // 3 + 1 return f"Q{q} {t.year}" return f"FY {t.year}" def _row_vals(frame: pd.DataFrame, label: str, n: int) -> list[float | None]: if frame is None or frame.empty or label not in frame.index: return [None] * n series = pd.to_numeric(frame.loc[label], errors="coerce") return [_safe_float(series.iloc[i]) if i < len(series) else None for i in range(n)] def _row_vals_multi(frame: pd.DataFrame, n: int, *labels: str) -> list[float | None]: for label in labels: vals = _row_vals(frame, label, n) if any(v is not None for v in vals): return vals return [None] * n def _fin_row(label: str, indent: int, is_total: bool, values: list[float | None]) -> dict: return {"label": label, "indent": indent, "is_total": is_total, "is_section": False, "is_margin": False, "values": values} def _fin_section(label: str) -> dict: return {"label": label, "indent": 0, "is_total": False, "is_section": True, "is_margin": False, "values": []} def _fin_margin(label: str, values: list[float | None]) -> dict: return {"label": label, "indent": 1, "is_total": False, "is_section": False, "is_margin": True, "values": values} def _safe_ratio(num: float | None, den: float | None) -> float | None: if num is None or den is None or den == 0: return None return num / den ``` - [ ] **Step 2: Verify compile-check passes** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/python -m py_compile backend/app/services/data_service.py ``` Expected: no output (clean compile). - [ ] **Step 3: Commit** ```bash git add backend/app/services/data_service.py git commit -m "feat: add financials cache and row-builder helper functions" ``` --- ## Task 3: Backend Data Service — Statement Builders **Files:** - Modify: `backend/app/services/data_service.py` - [ ] **Step 1: Write failing tests for statement builders** Add to `backend/tests/test_api.py`: ```python def annual_frame(rows: dict[str, list[float]]) -> pd.DataFrame: columns = pd.to_datetime(["2024-09-30", "2023-09-30", "2022-09-30", "2021-09-30"]) return pd.DataFrame(rows, index=columns).T def test_build_income_annual_columns_and_ttm(monkeypatch) -> None: data_service.STATEMENT_CACHE.clear() data_service.FINANCIALS_CACHE.clear() inc_annual = annual_frame({ "Total Revenue": [391_000.0, 383_300.0, 394_300.0, 365_800.0], "Gross Profit": [180_700.0, 169_100.0, 170_800.0, 152_800.0], "Net Income": [ 93_700.0, 97_000.0, 99_800.0, 94_700.0], }) inc_q = quarterly_frame({ "Total Revenue": [100_000.0, 95_000.0, 98_000.0, 97_000.0], "Gross Profit": [ 46_000.0, 44_000.0, 45_000.0, 43_000.0], "Net Income": [ 24_000.0, 23_000.0, 24_000.0, 22_000.0], }) monkeypatch.setattr(data_service, "get_income_statement", lambda sym, quarterly=False: inc_q if quarterly else inc_annual) monkeypatch.setattr(data_service, "get_balance_sheet", lambda sym, quarterly=False: pd.DataFrame()) monkeypatch.setattr(data_service, "get_cash_flow", lambda sym, quarterly=False: pd.DataFrame()) result = data_service.get_financials("AAPL", "annual") income = result["income"] assert income["columns"] == ["FY 2024", "FY 2023", "FY 2022", "FY 2021", "TTM"] rev_row = next(r for r in income["rows"] if r["label"] == "Total Revenue") assert rev_row["is_total"] is True assert rev_row["values"][4] == 390_000.0 # sum of 4 quarters margin_row = next(r for r in income["rows"] if r["label"] == "gross margin") assert margin_row["is_margin"] is True assert margin_row["values"][0] is not None # FY 2024 gross margin computed def test_build_income_quarterly_eight_columns(monkeypatch) -> None: data_service.STATEMENT_CACHE.clear() data_service.FINANCIALS_CACHE.clear() cols = pd.to_datetime([ "2025-12-31","2025-09-30","2025-06-30","2025-03-31", "2024-12-31","2024-09-30","2024-06-30","2024-03-31", ]) inc_q8 = pd.DataFrame( {"Total Revenue": [100_000.0]*8, "Net Income": [25_000.0]*8}, index=cols, ).T monkeypatch.setattr(data_service, "get_income_statement", lambda sym, quarterly=False: inc_q8) monkeypatch.setattr(data_service, "get_balance_sheet", lambda sym, quarterly=False: pd.DataFrame()) monkeypatch.setattr(data_service, "get_cash_flow", lambda sym, quarterly=False: pd.DataFrame()) result = data_service.get_financials("AAPL", "quarterly") income = result["income"] assert len(income["columns"]) == 8 assert income["columns"][0] == "Q4 2025" assert "TTM" not in income["columns"] def test_build_balance_mrq_column(monkeypatch) -> None: data_service.STATEMENT_CACHE.clear() data_service.FINANCIALS_CACHE.clear() bal_annual = annual_frame({"Total Assets": [364_900.0, 335_000.0, 352_800.0, 351_000.0]}) bal_q = quarterly_frame({"Total Assets": [371_900.0, 368_000.0, 360_000.0, 355_000.0]}) monkeypatch.setattr(data_service, "get_income_statement", lambda sym, quarterly=False: pd.DataFrame()) monkeypatch.setattr(data_service, "get_balance_sheet", lambda sym, quarterly=False: bal_q if quarterly else bal_annual) monkeypatch.setattr(data_service, "get_cash_flow", lambda sym, quarterly=False: pd.DataFrame()) result = data_service.get_financials("AAPL", "annual") balance = result["balance"] assert balance["columns"][-1] == "MRQ" assets_row = next(r for r in balance["rows"] if r["label"] == "Total Assets") assert assets_row["values"][-1] == 371_900.0 # MRQ value def test_build_cash_flow_fcf(monkeypatch) -> None: data_service.STATEMENT_CACHE.clear() data_service.FINANCIALS_CACHE.clear() cf_annual = annual_frame({ "Operating Cash Flow": [118_300.0, 110_500.0, 122_200.0, 104_000.0], "Capital Expenditure": [ -9_500.0, -10_900.0, -10_700.0, -8_600.0], }) cf_q = quarterly_frame({ "Operating Cash Flow": [30_000.0, 29_000.0, 31_000.0, 28_000.0], "Capital Expenditure": [-2_500.0, -2_400.0, -2_500.0, -2_400.0], }) inc_annual = annual_frame({"Total Revenue": [391_000.0, 383_300.0, 394_300.0, 365_800.0]}) inc_q = quarterly_frame({"Total Revenue": [100_000.0, 95_000.0, 98_000.0, 97_000.0]}) def mock_cf(sym, quarterly=False): return cf_q if quarterly else cf_annual def mock_inc(sym, quarterly=False): return inc_q if quarterly else inc_annual monkeypatch.setattr(data_service, "get_income_statement", mock_inc) monkeypatch.setattr(data_service, "get_balance_sheet", lambda sym, quarterly=False: pd.DataFrame()) monkeypatch.setattr(data_service, "get_cash_flow", mock_cf) result = data_service.get_financials("AAPL", "annual") cf = result["cash_flow"] fcf_row = next(r for r in cf["rows"] if r["label"] == "Free Cash Flow") assert fcf_row["is_total"] is True # FY 2024: 118300 + (-9500) = 108800 assert fcf_row["values"][0] == 108_800.0 fcf_margin = next(r for r in cf["rows"] if r["label"] == "FCF margin") assert fcf_margin["is_margin"] is True assert fcf_margin["values"][0] is not None def test_get_financials_empty_statements(monkeypatch) -> None: data_service.STATEMENT_CACHE.clear() data_service.FINANCIALS_CACHE.clear() monkeypatch.setattr(data_service, "get_income_statement", lambda sym, quarterly=False: pd.DataFrame()) monkeypatch.setattr(data_service, "get_balance_sheet", lambda sym, quarterly=False: pd.DataFrame()) monkeypatch.setattr(data_service, "get_cash_flow", lambda sym, quarterly=False: pd.DataFrame()) result = data_service.get_financials("AAPL", "annual") assert result["income"]["columns"] == [] assert result["income"]["rows"] == [] assert result["balance"]["columns"] == [] assert result["cash_flow"]["columns"] == [] ``` - [ ] **Step 2: Run tests to confirm they fail** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_build_income_annual_columns_and_ttm backend/tests/test_api.py::test_build_income_quarterly_eight_columns backend/tests/test_api.py::test_build_balance_mrq_column backend/tests/test_api.py::test_build_cash_flow_fcf backend/tests/test_api.py::test_get_financials_empty_statements -v ``` Expected: all FAIL with `AttributeError: module ... has no attribute 'get_financials'`. - [ ] **Step 3: Add statement builders and get_financials to data_service.py** Add after the `_safe_ratio` helper (end of the helper block added in Task 2): ```python def _build_income(frame: pd.DataFrame, frame_q: pd.DataFrame, quarterly: bool) -> dict: if frame is None or frame.empty: return {"columns": [], "rows": []} n = min(len(frame.columns), 8 if quarterly else 4) col_labels = [_fmt_col(c, quarterly) for c in frame.columns[:n]] if not quarterly: col_labels.append("TTM") def v(label: str) -> list[float | None]: base = _row_vals(frame, label, n) return base + ([_statement_ttm(frame_q, label)] if not quarterly else []) def vm(*labels: str) -> list[float | None]: base = _row_vals_multi(frame, n, *labels) if not quarterly: ttm = None for lbl in labels: ttm = _statement_ttm(frame_q, lbl) if ttm is not None: break base = base + [ttm] return base rev = v("Total Revenue") gross = v("Gross Profit") net = v("Net Income") return { "columns": col_labels, "rows": [ _fin_row("Total Revenue", 0, True, rev), _fin_row("Cost of Revenue", 1, False, v("Cost Of Revenue")), _fin_row("Gross Profit", 0, True, gross), _fin_margin("gross margin", [_safe_ratio(g, r) for g, r in zip(gross, rev)]), _fin_row("Operating Expenses", 1, False, v("Operating Expense")), _fin_row("Operating Income", 0, True, v("Operating Income")), _fin_row("EBITDA", 1, False, vm("EBITDA", "Normalized EBITDA")), _fin_row("Interest Expense", 1, False, v("Interest Expense")), _fin_row("Pretax Income", 0, False, v("Pretax Income")), _fin_row("Tax Provision", 1, False, v("Tax Provision")), _fin_row("Net Income", 0, True, net), _fin_margin("net margin", [_safe_ratio(ni, r) for ni, r in zip(net, rev)]), _fin_row("EPS Basic", 1, False, v("Basic EPS")), ], } def _build_balance(frame: pd.DataFrame, frame_q: pd.DataFrame, quarterly: bool) -> dict: if frame is None or frame.empty: return {"columns": [], "rows": []} n = min(len(frame.columns), 8 if quarterly else 4) col_labels = [_fmt_col(c, quarterly) for c in frame.columns[:n]] if not quarterly: col_labels.append("MRQ") def v(*labels: str) -> list[float | None]: base = _row_vals_multi(frame, n, *labels) if not quarterly: val = None for lbl in labels: val = _balance_value(frame_q, lbl) if val is not None: break base = base + [val] return base return { "columns": col_labels, "rows": [ _fin_section("ASSETS"), _fin_row("Current Assets", 0, True, v("Current Assets")), _fin_row("Cash & Equivalents", 1, False, v("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments")), _fin_row("Short Term Investments", 1, False, v("Other Short Term Investments", "Short Term Investments")), _fin_row("Receivables", 1, False, v("Receivables", "Net Receivables")), _fin_row("Inventory", 1, False, v("Inventory")), _fin_row("Total Assets", 0, True, v("Total Assets")), _fin_section("LIABILITIES"), _fin_row("Current Liabilities", 0, True, v("Current Liabilities")), _fin_row("Accounts Payable", 1, False, v("Payables And Accrued Expenses", "Accounts Payable")), _fin_row("Short Term Debt", 1, False, v("Current Debt", "Short Term Debt And Capital Lease Obligation")), _fin_row("Long Term Debt", 1, False, v("Long Term Debt", "Long Term Debt And Capital Lease Obligation")), _fin_row("Total Liabilities", 0, True, v("Total Liabilities Net Minority Interest", "Total Liabilities")), _fin_section("EQUITY"), _fin_row("Stockholders Equity", 0, True, v("Stockholders Equity", "Common Stock Equity")), ], } def _build_cash_flow(cf: pd.DataFrame, cf_q: pd.DataFrame, inc: pd.DataFrame, inc_q: pd.DataFrame, quarterly: bool) -> dict: if cf is None or cf.empty: return {"columns": [], "rows": []} n = min(len(cf.columns), 8 if quarterly else 4) col_labels = [_fmt_col(c, quarterly) for c in cf.columns[:n]] if not quarterly: col_labels.append("TTM") def cv(*labels: str) -> list[float | None]: base = _row_vals_multi(cf, n, *labels) if not quarterly: ttm = None for lbl in labels: ttm = _statement_ttm(cf_q, lbl) if ttm is not None: break base = base + [ttm] return base def iv(*labels: str) -> list[float | None]: base = _row_vals_multi(inc, n, *labels) if not quarterly: ttm = None for lbl in labels: ttm = _statement_ttm(inc_q, lbl) if ttm is not None: break base = base + [ttm] return base op_cf = cv("Operating Cash Flow", "Cash Flow From Continuing Operating Activities") capex = cv("Capital Expenditure") # CapEx is negative in yfinance; FCF = Operating CF + CapEx fcf = [a + b if a is not None and b is not None else None for a, b in zip(op_cf, capex)] rev = iv("Total Revenue") return { "columns": col_labels, "rows": [ _fin_section("OPERATING"), _fin_row("Net Income", 1, False, iv("Net Income")), _fin_row("D&A", 1, False, cv("Depreciation And Amortization", "Reconciled Depreciation")), _fin_row("Changes in Working Capital", 1, False, cv("Change In Working Capital")), _fin_row("Operating Cash Flow", 0, True, op_cf), _fin_section("INVESTING"), _fin_row("CapEx", 1, False, capex), _fin_row("Free Cash Flow", 0, True, fcf), _fin_margin("FCF margin", [_safe_ratio(f, r) for f, r in zip(fcf, rev)]), _fin_row("Investing Cash Flow", 0, True, cv("Investing Cash Flow", "Cash Flow From Continuing Investing Activities")), _fin_section("FINANCING"), _fin_row("Dividends Paid", 1, False, cv("Cash Dividends Paid", "Common Stock Dividend Paid")), _fin_row("Buybacks", 1, False, cv("Repurchase Of Capital Stock", "Common Stock Repurchase")), _fin_row("Financing Cash Flow", 0, True, cv("Financing Cash Flow", "Cash Flow From Continuing Financing Activities")), _fin_row("Net Change in Cash", 0, True, cv("Changes In Cash", "End Cash Position")), ], } @cached(FINANCIALS_CACHE) def get_financials(symbol: str, period: str = "annual") -> dict: sym = normalize_symbol(symbol) quarterly = period == "quarterly" inc = get_income_statement(sym, quarterly=quarterly) bal = get_balance_sheet(sym, quarterly=quarterly) cf = get_cash_flow(sym, quarterly=quarterly) inc_q = get_income_statement(sym, quarterly=True) if not quarterly else inc bal_q = get_balance_sheet(sym, quarterly=True) if not quarterly else bal cf_q = get_cash_flow(sym, quarterly=True) if not quarterly else cf return { "period": period, "income": _build_income(inc, inc_q, quarterly), "balance": _build_balance(bal, bal_q, quarterly), "cash_flow": _build_cash_flow(cf, cf_q, inc, inc_q, quarterly), } ``` Also update `clear_service_caches()` in `backend/tests/test_api.py` to clear FINANCIALS_CACHE: ```python def clear_service_caches() -> None: data_service.INFO_CACHE.clear() data_service.FAST_INFO_CACHE.clear() data_service.PRICE_CACHE.clear() data_service.HISTORY_CACHE.clear() data_service.STATEMENT_CACHE.clear() data_service.SHARES_CACHE.clear() data_service.RATIO_CACHE.clear() data_service.FINANCIALS_CACHE.clear() ``` - [ ] **Step 4: Run the new tests** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_build_income_annual_columns_and_ttm backend/tests/test_api.py::test_build_income_quarterly_eight_columns backend/tests/test_api.py::test_build_balance_mrq_column backend/tests/test_api.py::test_build_cash_flow_fcf backend/tests/test_api.py::test_get_financials_empty_statements -v ``` Expected: all PASS. - [ ] **Step 5: Run full test suite to confirm no regressions** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q ``` Expected: all existing tests + 5 new = 24 passed, 0 failures. - [ ] **Step 6: Commit** ```bash git add backend/app/services/data_service.py backend/tests/test_api.py git commit -m "feat: add get_financials() with income/balance/cashflow builders" ``` --- ## Task 4: Backend Route **Files:** - Modify: `backend/app/main.py` - Modify: `backend/tests/test_api.py` - [ ] **Step 1: Write the failing route test** Add to `backend/tests/test_api.py`: ```python def test_financials_route_returns_structure(monkeypatch) -> None: monkeypatch.setattr( main.data_service, "get_financials", lambda symbol, period="annual": { "period": "annual", "income": {"columns": ["FY 2024", "TTM"], "rows": [ {"label": "Total Revenue", "indent": 0, "is_total": True, "is_section": False, "is_margin": False, "values": [391_000.0, 394_500.0]}, ]}, "balance": {"columns": [], "rows": []}, "cash_flow": {"columns": [], "rows": []}, }, ) result = main.ticker_financials("AAPL", period="annual") assert result["period"] == "annual" assert result["income"]["columns"][0] == "FY 2024" assert result["income"]["rows"][0]["label"] == "Total Revenue" def test_financials_route_period_param(monkeypatch) -> None: captured: list[str] = [] def mock_get_financials(symbol, period="annual"): captured.append(period) return { "period": period, "income": {"columns": [], "rows": []}, "balance": {"columns": [], "rows": []}, "cash_flow": {"columns": [], "rows": []}, } monkeypatch.setattr(main.data_service, "get_financials", mock_get_financials) main.ticker_financials("AAPL", period="quarterly") assert captured == ["quarterly"] ``` - [ ] **Step 2: Run tests to confirm they fail** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_financials_route_returns_structure backend/tests/test_api.py::test_financials_route_period_param -v ``` Expected: FAIL — `AttributeError: module ... has no attribute 'ticker_financials'`. - [ ] **Step 3: Add route to main.py** Update the import line at the top of `main.py`: ```python from app.schemas import FinancialRow, FinancialStatement, FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse ``` Add route after `ticker_history`: ```python @app.get("/api/tickers/{symbol}/financials", response_model=FinancialsResponse) def ticker_financials(symbol: str, period: str = Query(default="annual", pattern="^(annual|quarterly)$")) -> dict: return data_service.get_financials(symbol, period=period) ``` - [ ] **Step 4: Run route tests** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/pytest backend/tests/test_api.py::test_financials_route_returns_structure backend/tests/test_api.py::test_financials_route_period_param -v ``` Expected: both PASS. - [ ] **Step 5: Run full suite** ```bash /home/tyler/Work/prism-v2/backend/.venv/bin/pytest --tb=short -q ``` Expected: 26 passed, 0 failures. - [ ] **Step 6: Commit** ```bash git add backend/app/main.py backend/tests/test_api.py git commit -m "feat: add /api/tickers/{symbol}/financials endpoint" ``` --- ## Task 5: Frontend Types **Files:** - Modify: `frontend/types/api.ts` - [ ] **Step 1: Add types** Append to `frontend/types/api.ts`: ```typescript 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; }; ``` - [ ] **Step 2: Validate with build** ```bash cd /home/tyler/Work/prism-v2/frontend && npm run build 2>&1 | tail -20 ``` Expected: no TypeScript errors related to the new types. - [ ] **Step 3: Commit** ```bash git add frontend/types/api.ts git commit -m "feat: add FinancialRow/Statement/Response frontend types" ``` --- ## Task 6: Frontend API Client + Nav **Files:** - Modify: `frontend/lib/api.ts` - Modify: `frontend/lib/overview.ts` - [ ] **Step 1: Add api.financials() to lib/api.ts** Update the import in `api.ts`: ```typescript import type { FinancialRow, FinancialStatement, FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse } from "@/types/api"; ``` Add to the `api` object: ```typescript financials(symbol: string, period: "annual" | "quarterly" = "annual") { return request( `/api/tickers/${encodeURIComponent(symbol)}/financials?period=${encodeURIComponent(period)}` ); }, ``` - [ ] **Step 2: Enable financials nav item in lib/overview.ts** Find this line in `OVERVIEW_NAV_ITEMS`: ```typescript { key: "financials", label: "Financials", icon: "ledger", disabled: true }, ``` Replace with: ```typescript { key: "financials", label: "Financials", icon: "ledger" }, ``` - [ ] **Step 3: Validate with lint + build** ```bash cd /home/tyler/Work/prism-v2/frontend && npm run lint 2>&1 | tail -10 && npm run build 2>&1 | tail -10 ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add frontend/lib/api.ts frontend/lib/overview.ts git commit -m "feat: add api.financials() and enable financials nav item" ``` --- ## Task 7: Frontend FinancialsCard Component **Files:** - Create: `frontend/components/prism/FinancialsCard.tsx` - Modify: `frontend/app/prism-shell.css` - [ ] **Step 1: Create FinancialsCard.tsx** ```typescript "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 = { 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 ( {row.label} ); } const cls = [ "psm-fin-row", row.is_total ? "is-total" : "", row.is_margin ? "is-margin" : "", row.indent === 1 ? "is-indent" : "", ] .filter(Boolean) .join(" "); return ( {row.label} {row.values.map((val, i) => ( {fmtFinVal(val, row.is_margin)} ))} ); } export function FinancialsCard({ data, statement, period, onChangeStatement, onChangePeriod }: Props) { const stmt = data[statement]; const lastColIdx = stmt.columns.length - 1; return (
{(["income", "balance", "cash_flow"] as StatementKey[]).map((key) => ( ))}
{(["annual", "quarterly"] as PeriodKey[]).map((p) => ( ))}
{stmt.columns.length === 0 ? (

Statement data unavailable.

) : (
{stmt.columns.map((col, i) => ( ))} {stmt.rows.map((row, i) => ( ))}
USD (millions) {col}
)}
); } ``` - [ ] **Step 2: Add CSS to prism-shell.css** Append to `frontend/app/prism-shell.css`: ```css /* ── 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); } ``` - [ ] **Step 3: Validate with lint + build** ```bash cd /home/tyler/Work/prism-v2/frontend && npm run lint 2>&1 | tail -10 && npm run build 2>&1 | tail -10 ``` Expected: clean. If unused import errors appear, remove the unused `FinancialRow` or `FinancialStatement` imports from FinancialsCard.tsx. - [ ] **Step 4: Commit** ```bash git add frontend/components/prism/FinancialsCard.tsx frontend/app/prism-shell.css git commit -m "feat: add FinancialsCard component with statement tabs and period toggle" ``` --- ## Task 8: Frontend FinancialsPage Component **Files:** - Create: `frontend/components/prism/FinancialsPage.tsx` - [ ] **Step 1: Create FinancialsPage.tsx** ```typescript "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("income"); const [period, setPeriod] = useState("annual"); const [data, setData] = useState(null); const [finState, setFinState] = useState("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 ( <> {finState === "loading" && (
)} {finState === "error" && (

Financial statements unavailable for {ticker}.

)} {finState === "ready" && data && ( )} ); } ``` - [ ] **Step 2: Validate with lint + build** ```bash cd /home/tyler/Work/prism-v2/frontend && npm run lint 2>&1 | tail -10 && npm run build 2>&1 | tail -10 ``` Expected: clean. - [ ] **Step 3: Commit** ```bash git add frontend/components/prism/FinancialsPage.tsx git commit -m "feat: add FinancialsPage data-fetching wrapper" ``` --- ## Task 9: Frontend Routing — Sidebar + page.tsx **Files:** - Modify: `frontend/components/prism/Sidebar.tsx` - Modify: `frontend/app/page.tsx` - [ ] **Step 1: Add onSelectTab to Sidebar** In `Sidebar.tsx`, update `Props` type: ```typescript type Props = { navItems: NavItem[]; selectedKey: string; currentTicker: string; watchlist: WatchlistResponse; watchlistError: string | null; onSelectTicker: (symbol: string) => void; onRemoveTicker: (symbol: string) => void; onSelectTab: (key: string) => void; }; ``` Update the function signature: ```typescript export function Sidebar({ navItems, selectedKey, currentTicker, watchlist, watchlistError, onSelectTicker, onRemoveTicker, onSelectTab, }: Props) { ``` Update the nav button to call onSelectTab when not disabled: ```typescript