From 1482422f2f5b236cdcdff4429ae06bb55dca4083 Mon Sep 17 00:00:00 2001
From: Tyler Hoang <tyler@tylerhoang.xyz>
Date: Sun, 17 May 2026 12:46:13 -0700
Subject: Add stack start and stop scripts

---
 frontend/app/page.tsx | 530 ++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 530 insertions(+)
 create mode 100644 frontend/app/page.tsx

(limited to 'frontend/app/page.tsx')

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;
+}
-- 
cgit v1.3-2-g0d8e

