From 1482422f2f5b236cdcdff4429ae06bb55dca4083 Mon Sep 17 00:00:00 2001 From: Tyler Hoang 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 ( +
Loading Prism...
}> + +
+ ); +} + +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([]); + const [searching, setSearching] = useState(false); + const [market, setMarket] = useState([]); + const [overview, setOverview] = useState(null); + const [history, setHistory] = useState([]); + const [watchlist, setWatchlist] = useState({ items: [], limit: 10 }); + const [period, setPeriod] = useState("1y"); + const [overviewState, setOverviewState] = useState("idle"); + const [overviewError, setOverviewError] = useState(null); + const [chartState, setChartState] = useState("idle"); + const [chartError, setChartError] = useState(null); + const [watchlistError, setWatchlistError] = useState(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) { + 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 ( +
+ + +
+ + {!selectedTicker && } + {selectedTicker && overviewState === "loading" &&
Loading {selectedTicker}...
} + {selectedTicker && overviewState === "invalid" && } + {selectedTicker && overviewState === "error" &&
{overviewError || "Ticker data unavailable"}
} + {overview && overviewState === "ready" && ( +
+
+
+
{overview.profile.exchange || "Ticker"}
+
+

{overview.profile.name}

+ {overview.meta.is_partial && Partial Data} +
+

{identityLine(overview)}

+
+
+ {fmtCurrency(overview.quote.price)} + {fmtPct(overview.quote.change_pct, 2, true)} + Prev close {fmtCurrency(overview.quote.prev_close)} +
+
+ + {overview.meta.is_partial &&
Some fields are unavailable for this ticker. Available data is still shown below.
} + +
+ {overview.signals.map((signal) => ( +
+ {signal.key} + {signal.value} + {signal.description} +
+ ))} +
+ +
+ + + + + + +
+ +
+ + +
+ +
+ + +
+ +
+
+
+
Price History
+ {overview.profile.symbol} +
+
+ {PERIODS.map((option) => ( + + ))} +
+
+ {chartState === "loading" &&
Loading {period.toUpperCase()} history...
} + {chartState === "error" &&
{chartError || "Could not load chart history"}
} + {chartState === "ready" && } +
+
+ )} +
+
+ ); +} + +function MarketBar({ indices }: { indices: MarketIndex[] }) { + if (!indices.length) return
Market data unavailable
; + return ( +
+ {indices.map((index) => ( +
+ {index.name} + {fmtNumber(index.price)} + {fmtPct(index.change_pct, 2, true)} +
+ ))} +
+ ); +} + +function EmptyState({ watchlist, onSelect }: { watchlist: WatchlistResponse; onSelect: (symbol: string) => void }) { + return ( +
+

Overview

+ {watchlist.items.length ? ( + <> +

Select a saved ticker to load its company profile, quote, stats, and chart.

+
+ {watchlist.items.map((item) => ( + + ))} +
+ + ) : ( +

Search for a ticker to begin.

+ )} +
+ ); +} + +function InvalidTickerState({ symbol, onClear }: { symbol: string; onClear: () => void }) { + return ( +
+

{symbol}

+

This ticker could not be resolved to usable market data.

+ +
+ ); +} + +function Stat({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) { + return ( +
+ {label} + {missing ? "Unavailable" : value} +
+ ); +} + +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 ( +
+
52 Week Range
+ {pct === null ? ( +
Range unavailable
+ ) : ( + <> +
+ {fmtCurrency(low)} + {fmtCurrency(price)} + {fmtCurrency(high)} +
+
+ + +
+ + )} +
+ ); +} + +function ShortInterest({ overview }: { overview: TickerOverview }) { + const short = overview.short_interest; + return ( +
+
Short Interest
+
+ + + + +
+
+ ); +} + +function ProfileCard({ overview }: { overview: TickerOverview }) { + return ( +
+
Company Profile
+
+
+ Sector + {overview.profile.sector || "Unavailable"} +
+
+ Industry + {overview.profile.industry || "Unavailable"} +
+
+ Website + {overview.profile.website ? ( + + {overview.profile.website} + + ) : ( + Unavailable + )} +
+
+

+ {overview.profile.summary || "Business summary unavailable."} +

+
+ ); +} + +function SourceCard({ overview }: { overview: TickerOverview }) { + const fields = Object.entries(overview.meta.sources).slice(0, 6); + return ( +
+
Data Quality
+
+ Status {overview.meta.status} +
+
+ {fields.length ? ( + fields.map(([field, source]) => ( +
+ {field} + {source} +
+ )) + ) : ( +
+ Sources + Unavailable +
+ )} +
+
+ ); +} + +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