diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-17 12:46:13 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-17 12:46:13 -0700 |
| commit | 1482422f2f5b236cdcdff4429ae06bb55dca4083 (patch) | |
| tree | 4653cb4986a8a138f84dbec934effb0d011751d3 /frontend/app | |
Add stack start and stop scripts
Diffstat (limited to 'frontend/app')
| -rw-r--r-- | frontend/app/globals.css | 646 | ||||
| -rw-r--r-- | frontend/app/layout.tsx | 15 | ||||
| -rw-r--r-- | frontend/app/page.tsx | 530 |
3 files changed, 1191 insertions, 0 deletions
diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..2756364 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,646 @@ +@import url("https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;500;600&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap"); + +:root { + --ink-0: #0b0e13; + --ink-1: #11151c; + --ink-2: #181d26; + --ink-3: #222934; + --line-1: #232934; + --line-2: #2e3645; + --fg-1: #f2ecdc; + --fg-2: #c7c0ae; + --fg-3: #8e8676; + --fg-4: #5e5849; + --brass: #c2aa7a; + --brass-bright: #dcc79e; + --brass-ink: #17120a; + --positive: #4f8c5e; + --negative: #b5494b; + --warning: #c49545; + --info: #4a78b5; + --font-display: "EB Garamond", Georgia, serif; + --font-sans: "IBM Plex Sans", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; + background: var(--ink-0); + color: var(--fg-2); + font-family: var(--font-sans); +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +.shell { + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + overflow: auto; + border-right: 1px solid var(--line-1); + background: var(--ink-1); + padding: 22px 18px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 22px; + border-bottom: 1px solid var(--line-1); +} + +.brand-mark { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid var(--brass); + color: var(--brass-bright); + font-family: var(--font-display); + font-size: 24px; +} + +.brand-name { + color: var(--fg-1); + font-family: var(--font-display); + font-size: 24px; +} + +.brand-sub, +.section-label, +.search-form label { + color: var(--fg-4); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.search-form, +.selected-summary, +.watchlist { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 24px; +} + +.search-box { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line-2); + background: var(--ink-2); + padding: 10px 12px; +} + +.search-box input { + min-width: 0; + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--fg-1); + font-family: var(--font-mono); +} + +.search-results { + border: 1px solid var(--line-1); + background: var(--ink-2); +} + +.search-results button, +.watch-row, +.watch-table button { + display: grid; + width: 100%; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px; + border: 0; + border-bottom: 1px solid var(--line-1); + background: transparent; + color: var(--fg-2); + padding: 9px 10px; + text-align: left; +} + +.search-results button:hover, +.watch-row:hover, +.watch-table button:hover { + background: var(--ink-3); +} + +.search-results span, +.watch-row span:first-child, +.watch-table span:first-child { + color: var(--fg-1); + font-family: var(--font-mono); +} + +.search-results small { + overflow: hidden; + color: var(--fg-3); + text-overflow: ellipsis; + white-space: nowrap; +} + +.muted-row, +.empty-copy { + color: var(--fg-4); + font-size: 13px; + padding: 8px 0; +} + +.summary-symbol { + color: var(--fg-1); + font-family: var(--font-display); + font-size: 32px; +} + +.summary-name { + color: var(--fg-3); +} + +.summary-price { + display: flex; + justify-content: space-between; + color: var(--fg-1); + font-family: var(--font-mono); +} + +.watch-action { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 0; + background: var(--brass); + color: var(--brass-ink); + padding: 9px 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.watch-row { + grid-template-columns: 1fr auto auto; + font-family: var(--font-mono); + padding-left: 0; + padding-right: 0; +} + +.watch-count { + color: var(--fg-4); + font-family: var(--font-mono); + font-size: 12px; + text-align: right; +} + +.content { + min-width: 0; + padding: 24px; +} + +.market-bar { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 22px; +} + +.market-item, +.stat, +.signal, +.detail-panel, +.chart-shell, +.empty-state, +.empty-panel, +.error-panel { + border: 1px solid var(--line-1); + background: var(--ink-1); +} + +.market-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; +} + +.market-item span, +.stat span, +.signal span { + color: var(--fg-4); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.market-item strong, +.stat strong { + color: var(--fg-1); + font-family: var(--font-mono); + font-size: 20px; + font-weight: 500; +} + +.positive { + color: var(--positive) !important; +} + +.negative { + color: var(--negative) !important; +} + +.neutral { + color: var(--fg-4) !important; +} + +.overview { + display: flex; + flex-direction: column; + gap: 16px; +} + +.company-header { + display: flex; + justify-content: space-between; + gap: 24px; + padding: 22px 0 12px; + border-bottom: 1px solid var(--line-1); +} + +.header-title-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.eyebrow { + color: var(--brass); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +h1 { + margin: 4px 0; + color: var(--fg-1); + font-family: var(--font-display); + font-size: 42px; + font-weight: 500; + line-height: 1.05; +} + +.company-header p { + margin: 0; + color: var(--fg-3); +} + +.quote-block { + display: flex; + min-width: 180px; + flex-direction: column; + align-items: flex-end; + gap: 6px; + font-family: var(--font-mono); +} + +.quote-block span { + color: var(--fg-1); + font-size: 28px; +} + +.quote-block small { + color: var(--fg-3); +} + +.status-chip { + border: 1px solid var(--warning); + color: var(--warning); + padding: 5px 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.signal-grid, +.stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.stat-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.signal, +.stat { + display: flex; + min-height: 92px; + flex-direction: column; + gap: 6px; + justify-content: center; + padding: 14px; +} + +.signal strong { + color: var(--fg-1); + font-family: var(--font-mono); + font-size: 17px; +} + +.signal small { + color: var(--fg-3); +} + +.inline-note { + border: 1px solid var(--line-1); + background: var(--ink-2); + color: var(--fg-3); + padding: 12px 14px; +} + +.error-copy { + border-color: rgba(181, 73, 75, 0.35); + color: var(--negative); +} + +.signal.pos strong { + color: var(--positive); +} + +.signal.warn strong { + color: var(--warning); +} + +.signal.neg strong { + color: var(--negative); +} + +.split-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.detail-panel { + padding: 16px; +} + +.range-values { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 12px; + margin-top: 18px; + color: var(--fg-3); + font-family: var(--font-mono); +} + +.range-values strong { + color: var(--brass-bright); +} + +.range-values span:last-child { + text-align: right; +} + +.range-rail { + position: relative; + height: 4px; + margin-top: 16px; + background: var(--line-2); +} + +.range-rail span { + position: absolute; + inset: 0 auto 0 0; + background: var(--brass); +} + +.range-rail i { + position: absolute; + top: -4px; + width: 2px; + height: 12px; + background: var(--brass-bright); +} + +.mini-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} + +.mini-grid .stat { + min-height: 72px; + background: var(--ink-2); +} + +.chart-shell { + padding: 16px; +} + +.chart-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 10px; +} + +.chart-head strong { + color: var(--fg-1); + font-family: var(--font-display); + font-size: 24px; + font-weight: 500; +} + +.segmented { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.segmented button { + min-width: 42px; + border: 1px solid var(--line-2); + background: var(--ink-2); + color: var(--fg-3); + padding: 7px 10px; + font-size: 12px; +} + +.segmented button.active { + border-color: var(--brass); + color: var(--brass-bright); +} + +.chart { + width: 100%; +} + +.compact-panel { + padding: 18px; +} + +.empty-state, +.empty-panel, +.error-panel { + padding: 32px; +} + +.empty-state p, +.empty-panel, +.error-panel { + color: var(--fg-3); +} + +.error-panel { + border-color: var(--negative); + color: var(--negative); +} + +.invalid-state p { + margin: 12px 0 20px; +} + +.ghost-action { + border: 1px solid var(--line-2); + background: transparent; + color: var(--fg-1); + padding: 10px 14px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 12px; + font-weight: 700; +} + +.missing-value { + color: var(--fg-4) !important; +} + +.profile-list { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.profile-list div { + display: grid; + gap: 4px; +} + +.profile-list span { + color: var(--fg-4); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.profile-list strong, +.profile-list a, +.quality-copy strong { + color: var(--fg-1); + font-family: var(--font-mono); + font-weight: 500; + word-break: break-word; +} + +.profile-list a { + text-decoration: none; +} + +.profile-summary { + margin: 18px 0 0; + color: var(--fg-3); + line-height: 1.6; +} + +.quality-copy { + margin-top: 12px; + color: var(--fg-3); +} + +.compact-list { + margin-top: 16px; +} + +.watch-table { + max-width: 520px; + margin-top: 18px; + border: 1px solid var(--line-1); +} + +.watch-table button { + grid-template-columns: 1fr auto auto; +} + +@media (max-width: 1100px) { + .shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: relative; + height: auto; + } + + .market-bar, + .signal-grid, + .stat-grid, + .split-row { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 680px) { + .content { + padding: 16px; + } + + .market-bar, + .signal-grid, + .stat-grid, + .split-row { + grid-template-columns: 1fr; + } + + .company-header, + .chart-head { + align-items: flex-start; + flex-direction: column; + } + + .quote-block { + align-items: flex-start; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..744f0c5 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Prism v2", + description: "Financial overview dashboard" +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + <html lang="en"> + <body>{children}</body> + </html> + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..0658929 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,530 @@ +"use client"; + +import { FormEvent, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { BookmarkMinus, BookmarkPlus, Search } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { PriceChart } from "@/components/PriceChart"; +import { ApiError, api } from "@/lib/api"; +import { deltaClass, fmtCurrency, fmtLarge, fmtNumber, fmtPct } from "@/lib/format"; +import type { HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse } from "@/types/api"; + +const PERIODS = [ + { key: "1m", label: "1M" }, + { key: "3m", label: "3M" }, + { key: "6m", label: "6M" }, + { key: "1y", label: "1Y" }, + { key: "5y", label: "5Y" } +]; + +type LoadState = "idle" | "loading" | "ready" | "invalid" | "error"; +type ChartState = "idle" | "loading" | "ready" | "error"; + +export default function OverviewPage() { + return ( + <Suspense fallback={<main className="shell"><section className="content"><div className="empty-panel">Loading Prism...</div></section></main>}> + <OverviewClient /> + </Suspense> + ); +} + +function OverviewClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const selectedTicker = (searchParams.get("ticker") || "").toUpperCase(); + const lastTickerRef = useRef(""); + + const [query, setQuery] = useState(""); + const [results, setResults] = useState<SearchResult[]>([]); + const [searching, setSearching] = useState(false); + const [market, setMarket] = useState<MarketIndex[]>([]); + const [overview, setOverview] = useState<TickerOverview | null>(null); + const [history, setHistory] = useState<HistoryPoint[]>([]); + const [watchlist, setWatchlist] = useState<WatchlistResponse>({ items: [], limit: 10 }); + const [period, setPeriod] = useState("1y"); + const [overviewState, setOverviewState] = useState<LoadState>("idle"); + const [overviewError, setOverviewError] = useState<string | null>(null); + const [chartState, setChartState] = useState<ChartState>("idle"); + const [chartError, setChartError] = useState<string | null>(null); + const [watchlistError, setWatchlistError] = useState<string | null>(null); + + const watchlistSymbols = useMemo(() => new Set(watchlist.items.map((item) => item.symbol)), [watchlist]); + const isSaved = selectedTicker ? watchlistSymbols.has(selectedTicker) : false; + + const selectTicker = useCallback( + (symbol: string) => { + const normalized = symbol.trim().toUpperCase(); + if (!normalized) return; + setResults([]); + setQuery(""); + setOverviewError(null); + setChartError(null); + setWatchlistError(null); + setOverviewState("loading"); + setChartState("loading"); + setOverview(null); + setHistory([]); + router.push(`/?ticker=${encodeURIComponent(normalized)}`); + }, + [router] + ); + + const clearTicker = useCallback(() => { + setOverview(null); + setHistory([]); + setOverviewError(null); + setChartError(null); + setWatchlistError(null); + setOverviewState("idle"); + setChartState("idle"); + router.push("/"); + }, [router]); + + const refreshWatchlist = useCallback(async () => { + try { + setWatchlist(await api.watchlist()); + } catch { + setWatchlist({ items: [], limit: 10 }); + } + }, []); + + useEffect(() => { + api.marketIndices().then(setMarket).catch(() => setMarket([])); + refreshWatchlist(); + }, [refreshWatchlist]); + + useEffect(() => { + if (query.trim().length < 2) { + setResults([]); + return; + } + let cancelled = false; + setSearching(true); + const timer = window.setTimeout(() => { + api + .search(query) + .then((rows) => { + if (!cancelled) setResults(rows); + }) + .catch(() => { + if (!cancelled) setResults([]); + }) + .finally(() => { + if (!cancelled) setSearching(false); + }); + }, 250); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [query]); + + useEffect(() => { + if (!selectedTicker) { + lastTickerRef.current = ""; + setOverview(null); + setHistory([]); + setOverviewError(null); + setChartError(null); + setWatchlistError(null); + setOverviewState("idle"); + setChartState("idle"); + return; + } + if (lastTickerRef.current === selectedTicker) return; + + let cancelled = false; + lastTickerRef.current = selectedTicker; + setOverviewState("loading"); + setOverviewError(null); + setWatchlistError(null); + setOverview(null); + + api + .overview(selectedTicker) + .then((overviewData) => { + if (cancelled) return; + setOverview(overviewData); + setOverviewState("ready"); + }) + .catch((exc: Error) => { + if (cancelled) return; + setOverview(null); + if (exc instanceof ApiError && exc.status === 404) { + setOverviewState("invalid"); + setOverviewError("Ticker not found"); + setChartState("idle"); + setHistory([]); + return; + } + setOverviewState("error"); + setOverviewError(exc.message || "Ticker data unavailable"); + }); + + return () => { + cancelled = true; + }; + }, [selectedTicker]); + + useEffect(() => { + if (!selectedTicker) return; + let cancelled = false; + setChartState("loading"); + setChartError(null); + setHistory([]); + + api + .history(selectedTicker, period) + .then((historyData) => { + if (cancelled) return; + setHistory(historyData); + setChartState("ready"); + }) + .catch((exc: Error) => { + if (cancelled) return; + setHistory([]); + setChartState("error"); + setChartError(exc.message || "Could not load chart history"); + }); + + return () => { + cancelled = true; + }; + }, [selectedTicker, period]); + + async function onSearchSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + if (results[0]) selectTicker(results[0].symbol); + else if (query.trim()) selectTicker(query); + } + + async function toggleWatchlist() { + if (!selectedTicker) return; + try { + const next = isSaved ? await api.removeWatchlist(selectedTicker) : await api.addWatchlist(selectedTicker); + setWatchlist(next); + setWatchlistError(null); + } catch (exc) { + setWatchlistError(exc instanceof Error ? exc.message : "Could not update watchlist"); + } + } + + const summaryTicker = overview?.profile.symbol || selectedTicker; + const summaryName = + overviewState === "invalid" + ? "Ticker not found" + : overview?.profile.name || (selectedTicker ? "Loading ticker..." : "No ticker selected"); + + return ( + <main className="shell"> + <aside className="sidebar"> + <div className="brand"> + <div className="brand-mark">P</div> + <div> + <div className="brand-name">Prism</div> + <div className="brand-sub">Overview</div> + </div> + </div> + + <form className="search-form" onSubmit={onSearchSubmit}> + <label htmlFor="ticker-search">Ticker Search</label> + <div className="search-box"> + <Search size={16} aria-hidden /> + <input id="ticker-search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="AAPL, Microsoft..." /> + </div> + {query.trim().length >= 2 && ( + <div className="search-results"> + {searching && <div className="muted-row">Searching...</div>} + {!searching && results.length === 0 && <div className="muted-row">No matches</div>} + {results.map((result) => ( + <button key={`${result.symbol}-${result.exchange}`} type="button" onClick={() => selectTicker(result.symbol)}> + <span>{result.symbol}</span> + <small>{result.name}</small> + </button> + ))} + </div> + )} + </form> + + <section className="selected-summary"> + <div className="section-label">Selected</div> + <div className="summary-symbol">{summaryTicker || "-"}</div> + <div className="summary-name">{summaryName}</div> + {overview ? ( + <> + <div className="summary-price"> + {fmtCurrency(overview.quote.price)} + <span className={deltaClass(overview.quote.change_pct)}>{fmtPct(overview.quote.change_pct, 2, true)}</span> + </div> + <button className="watch-action" type="button" onClick={toggleWatchlist}> + {isSaved ? <BookmarkMinus size={16} aria-hidden /> : <BookmarkPlus size={16} aria-hidden />} + {isSaved ? "Remove" : "Save"} + </button> + </> + ) : selectedTicker ? ( + <div className="empty-copy">{overviewState === "loading" ? "Fetching overview..." : "Search for another ticker."}</div> + ) : ( + <div className="empty-copy">No ticker selected</div> + )} + {watchlistError && <div className="inline-note error-copy">{watchlistError}</div>} + </section> + + <section className="watchlist"> + <div className="section-label">Watchlist</div> + {watchlist.items.length === 0 && <div className="empty-copy">No saved tickers</div>} + {watchlist.items.map((item) => ( + <button key={item.symbol} type="button" className="watch-row" onClick={() => selectTicker(item.symbol)}> + <span>{item.symbol}</span> + <span>{fmtCurrency(item.quote?.price)}</span> + <span className={deltaClass(item.quote?.change_pct)}>{fmtPct(item.quote?.change_pct, 2, true)}</span> + </button> + ))} + <div className="watch-count"> + {watchlist.items.length}/{watchlist.limit} + </div> + </section> + </aside> + + <section className="content"> + <MarketBar indices={market} /> + {!selectedTicker && <EmptyState watchlist={watchlist} onSelect={selectTicker} />} + {selectedTicker && overviewState === "loading" && <div className="empty-panel">Loading {selectedTicker}...</div>} + {selectedTicker && overviewState === "invalid" && <InvalidTickerState symbol={selectedTicker} onClear={clearTicker} />} + {selectedTicker && overviewState === "error" && <div className="error-panel">{overviewError || "Ticker data unavailable"}</div>} + {overview && overviewState === "ready" && ( + <article className="overview"> + <header className="company-header"> + <div> + <div className="eyebrow">{overview.profile.exchange || "Ticker"}</div> + <div className="header-title-row"> + <h1>{overview.profile.name}</h1> + {overview.meta.is_partial && <span className="status-chip">Partial Data</span>} + </div> + <p>{identityLine(overview)}</p> + </div> + <div className="quote-block"> + <span>{fmtCurrency(overview.quote.price)}</span> + <strong className={deltaClass(overview.quote.change_pct)}>{fmtPct(overview.quote.change_pct, 2, true)}</strong> + <small>Prev close {fmtCurrency(overview.quote.prev_close)}</small> + </div> + </header> + + {overview.meta.is_partial && <div className="inline-note">Some fields are unavailable for this ticker. Available data is still shown below.</div>} + + <section className="signal-grid"> + {overview.signals.map((signal) => ( + <div className={`signal ${signal.state}`} key={signal.key}> + <span>{signal.key}</span> + <strong>{signal.value}</strong> + <small>{signal.description}</small> + </div> + ))} + </section> + + <section className="stat-grid"> + <Stat label="Market Cap" value={fmtLarge(overview.stats.market_cap)} missing={overview.stats.market_cap == null} /> + <Stat label="P/E TTM" value={fmtNumber(overview.stats.trailing_pe)} missing={overview.stats.trailing_pe == null} /> + <Stat label="EPS TTM" value={fmtCurrency(overview.stats.trailing_eps)} missing={overview.stats.trailing_eps == null} /> + <Stat label="Volume" value={fmtNumber(overview.stats.volume, 0)} missing={overview.stats.volume == null} /> + <Stat label="Avg Volume" value={fmtNumber(overview.stats.average_volume, 0)} missing={overview.stats.average_volume == null} /> + <Stat label="Beta" value={fmtNumber(overview.stats.beta)} missing={overview.stats.beta == null} /> + </section> + + <section className="split-row"> + <RangeCard overview={overview} /> + <ShortInterest overview={overview} /> + </section> + + <section className="split-row"> + <ProfileCard overview={overview} /> + <SourceCard overview={overview} /> + </section> + + <section className="chart-shell"> + <div className="chart-head"> + <div> + <div className="section-label">Price History</div> + <strong>{overview.profile.symbol}</strong> + </div> + <div className="segmented"> + {PERIODS.map((option) => ( + <button key={option.key} type="button" className={period === option.key ? "active" : ""} onClick={() => setPeriod(option.key)}> + {option.label} + </button> + ))} + </div> + </div> + {chartState === "loading" && <div className="empty-panel compact-panel">Loading {period.toUpperCase()} history...</div>} + {chartState === "error" && <div className="error-panel compact-panel">{chartError || "Could not load chart history"}</div>} + {chartState === "ready" && <PriceChart symbol={overview.profile.symbol} points={history} />} + </section> + </article> + )} + </section> + </main> + ); +} + +function MarketBar({ indices }: { indices: MarketIndex[] }) { + if (!indices.length) return <div className="market-bar empty">Market data unavailable</div>; + return ( + <section className="market-bar"> + {indices.map((index) => ( + <div className="market-item" key={index.name}> + <span>{index.name}</span> + <strong>{fmtNumber(index.price)}</strong> + <small className={deltaClass(index.change_pct)}>{fmtPct(index.change_pct, 2, true)}</small> + </div> + ))} + </section> + ); +} + +function EmptyState({ watchlist, onSelect }: { watchlist: WatchlistResponse; onSelect: (symbol: string) => void }) { + return ( + <section className="empty-state"> + <h1>Overview</h1> + {watchlist.items.length ? ( + <> + <p>Select a saved ticker to load its company profile, quote, stats, and chart.</p> + <div className="watch-table"> + {watchlist.items.map((item) => ( + <button key={item.symbol} type="button" onClick={() => onSelect(item.symbol)}> + <span>{item.symbol}</span> + <span>{fmtCurrency(item.quote?.price)}</span> + <span className={deltaClass(item.quote?.change_pct)}>{fmtPct(item.quote?.change_pct, 2, true)}</span> + </button> + ))} + </div> + </> + ) : ( + <p>Search for a ticker to begin.</p> + )} + </section> + ); +} + +function InvalidTickerState({ symbol, onClear }: { symbol: string; onClear: () => void }) { + return ( + <section className="error-panel invalid-state"> + <h1>{symbol}</h1> + <p>This ticker could not be resolved to usable market data.</p> + <button type="button" className="ghost-action" onClick={onClear}> + Back to Search + </button> + </section> + ); +} + +function Stat({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) { + return ( + <div className="stat"> + <span>{label}</span> + <strong className={missing ? "missing-value" : undefined}>{missing ? "Unavailable" : value}</strong> + </div> + ); +} + +function RangeCard({ overview }: { overview: TickerOverview }) { + const low = overview.range_52w.low; + const high = overview.range_52w.high; + const price = overview.range_52w.price; + const pct = low != null && high != null && price != null && high > low ? Math.max(0, Math.min(100, ((price - low) / (high - low)) * 100)) : null; + return ( + <div className="detail-panel"> + <div className="section-label">52 Week Range</div> + {pct === null ? ( + <div className="empty-copy">Range unavailable</div> + ) : ( + <> + <div className="range-values"> + <span>{fmtCurrency(low)}</span> + <strong>{fmtCurrency(price)}</strong> + <span>{fmtCurrency(high)}</span> + </div> + <div className="range-rail"> + <span style={{ width: `${pct}%` }} /> + <i style={{ left: `${pct}%` }} /> + </div> + </> + )} + </div> + ); +} + +function ShortInterest({ overview }: { overview: TickerOverview }) { + const short = overview.short_interest; + return ( + <div className="detail-panel"> + <div className="section-label">Short Interest</div> + <div className="mini-grid"> + <Stat label="Short Float" value={fmtPct(short.short_percent_of_float)} missing={short.short_percent_of_float == null} /> + <Stat label="Days Cover" value={fmtNumber(short.short_ratio)} missing={short.short_ratio == null} /> + <Stat label="Shares Short" value={fmtNumber(short.shares_short, 0)} missing={short.shares_short == null} /> + <Stat label="Prior Delta" value={fmtPct(short.shares_short_delta_pct, 1, true)} missing={short.shares_short_delta_pct == null} /> + </div> + </div> + ); +} + +function ProfileCard({ overview }: { overview: TickerOverview }) { + return ( + <div className="detail-panel"> + <div className="section-label">Company Profile</div> + <div className="profile-list"> + <div> + <span>Sector</span> + <strong className={!overview.profile.sector ? "missing-value" : undefined}>{overview.profile.sector || "Unavailable"}</strong> + </div> + <div> + <span>Industry</span> + <strong className={!overview.profile.industry ? "missing-value" : undefined}>{overview.profile.industry || "Unavailable"}</strong> + </div> + <div> + <span>Website</span> + {overview.profile.website ? ( + <a href={overview.profile.website} target="_blank" rel="noreferrer"> + {overview.profile.website} + </a> + ) : ( + <strong className="missing-value">Unavailable</strong> + )} + </div> + </div> + <p className={overview.profile.summary ? "profile-summary" : "profile-summary missing-value"}> + {overview.profile.summary || "Business summary unavailable."} + </p> + </div> + ); +} + +function SourceCard({ overview }: { overview: TickerOverview }) { + const fields = Object.entries(overview.meta.sources).slice(0, 6); + return ( + <div className="detail-panel"> + <div className="section-label">Data Quality</div> + <div className="quality-copy"> + Status <strong>{overview.meta.status}</strong> + </div> + <div className="profile-list compact-list"> + {fields.length ? ( + fields.map(([field, source]) => ( + <div key={field}> + <span>{field}</span> + <strong>{source}</strong> + </div> + )) + ) : ( + <div> + <span>Sources</span> + <strong className="missing-value">Unavailable</strong> + </div> + )} + </div> + </div> + ); +} + +function identityLine(overview: TickerOverview) { + const parts = [overview.profile.symbol, overview.profile.sector, overview.profile.industry].filter(Boolean); + return parts.length ? parts.join(" ยท ") : overview.profile.symbol; +} |
