summaryrefslogtreecommitdiff
path: root/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/app')
-rw-r--r--frontend/app/globals.css646
-rw-r--r--frontend/app/layout.tsx15
-rw-r--r--frontend/app/page.tsx530
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;
+}