diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/page.tsx | 63 | ||||
| -rw-r--r-- | frontend/app/prism-shell.css | 184 | ||||
| -rw-r--r-- | frontend/components/prism/FinancialsCard.tsx | 136 | ||||
| -rw-r--r-- | frontend/components/prism/FinancialsPage.tsx | 73 | ||||
| -rw-r--r-- | frontend/components/prism/Sidebar.tsx | 5 | ||||
| -rw-r--r-- | frontend/lib/api.ts | 7 | ||||
| -rw-r--r-- | frontend/lib/overview.ts | 2 | ||||
| -rw-r--r-- | frontend/types/api.ts | 21 |
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; +}; |
