From 62bdd79b3473262dde5fb0a90eab34fe7bf344fd Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Sun, 17 May 2026 13:07:40 -0700 Subject: 'UI Shell and General Architecture' --- frontend/app/page.tsx | 581 +++++++++++++++++++++++++------------------------- 1 file changed, 296 insertions(+), 285 deletions(-) (limited to 'frontend/app/page.tsx') diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 0658929..f47d49a 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,27 +1,25 @@ "use client"; -import { FormEvent, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { BookmarkMinus, BookmarkPlus, Search } from "lucide-react"; +import { FormEvent, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; -import { PriceChart } from "@/components/PriceChart"; +import { AppShell } from "@/components/prism/AppShell"; +import { ChartCard } from "@/components/prism/ChartCard"; +import { KPIStrip } from "@/components/prism/KPIStrip"; +import { Sidebar } from "@/components/prism/Sidebar"; +import { TickerHeader } from "@/components/prism/TickerHeader"; +import { TopBar } from "@/components/prism/TopBar"; import { ApiError, api } from "@/lib/api"; import { deltaClass, fmtCurrency, fmtLarge, fmtNumber, fmtPct } from "@/lib/format"; +import { availableFieldSummary, buildKpis, marketClock, OVERVIEW_NAV_ITEMS, signalTone, sortIndices, unavailableFields } from "@/lib/overview"; 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...
}> + }> ); @@ -46,24 +44,37 @@ function OverviewClient() { const [chartState, setChartState] = useState("idle"); const [chartError, setChartError] = useState(null); const [watchlistError, setWatchlistError] = useState(null); + const [clockSnapshot, setClockSnapshot] = useState(() => marketClock()); const watchlistSymbols = useMemo(() => new Set(watchlist.items.map((item) => item.symbol)), [watchlist]); const isSaved = selectedTicker ? watchlistSymbols.has(selectedTicker) : false; + const marketCards = useMemo(() => sortIndices(market), [market]); + const kpis = useMemo(() => (overview ? buildKpis(overview) : []), [overview]); + const missingFields = useMemo(() => (overview ? unavailableFields(overview) : []), [overview]); + + useEffect(() => { + const timer = window.setInterval(() => setClockSnapshot(marketClock()), 60_000); + return () => window.clearInterval(timer); + }, []); - const selectTicker = useCallback( + const navigateToTicker = useCallback( (symbol: string) => { const normalized = symbol.trim().toUpperCase(); if (!normalized) return; + setResults([]); setQuery(""); + setOverview(null); + setHistory([]); setOverviewError(null); setChartError(null); setWatchlistError(null); setOverviewState("loading"); setChartState("loading"); - setOverview(null); - setHistory([]); - router.push(`/?ticker=${encodeURIComponent(normalized)}`); + + startTransition(() => { + router.push(`/?ticker=${encodeURIComponent(normalized)}`); + }); }, [router] ); @@ -76,7 +87,9 @@ function OverviewClient() { setWatchlistError(null); setOverviewState("idle"); setChartState("idle"); - router.push("/"); + startTransition(() => { + router.push("/"); + }); }, [router]); const refreshWatchlist = useCallback(async () => { @@ -95,10 +108,13 @@ function OverviewClient() { useEffect(() => { if (query.trim().length < 2) { setResults([]); + setSearching(false); return; } + let cancelled = false; setSearching(true); + const timer = window.setTimeout(() => { api .search(query) @@ -112,6 +128,7 @@ function OverviewClient() { if (!cancelled) setSearching(false); }); }, 250); + return () => { cancelled = true; window.clearTimeout(timer); @@ -125,18 +142,17 @@ function OverviewClient() { 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 @@ -149,13 +165,15 @@ function OverviewClient() { .catch((exc: Error) => { if (cancelled) return; setOverview(null); + setChartState("idle"); + setHistory([]); + 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"); }); @@ -167,6 +185,7 @@ function OverviewClient() { useEffect(() => { if (!selectedTicker) return; + let cancelled = false; setChartState("loading"); setChartError(null); @@ -193,11 +212,14 @@ function OverviewClient() { async function onSearchSubmit(event: FormEvent) { event.preventDefault(); - if (results[0]) selectTicker(results[0].symbol); - else if (query.trim()) selectTicker(query); + if (results[0]) { + navigateToTicker(results[0].symbol); + return; + } + if (query.trim()) navigateToTicker(query); } - async function toggleWatchlist() { + async function addOrRemoveCurrentTicker() { if (!selectedTicker) return; try { const next = isSaved ? await api.removeWatchlist(selectedTicker) : await api.addWatchlist(selectedTicker); @@ -208,323 +230,312 @@ function OverviewClient() { } } - 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" && } -
-
- )} -
-
+ + + + + + ) : null} + ); + + return shell; } -function MarketBar({ indices }: { indices: MarketIndex[] }) { - if (!indices.length) return
Market data unavailable
; +function MarketStrip({ indices }: { indices: MarketIndex[] }) { + if (!indices.length) { + return
Market data is temporarily unavailable.
; + } + return ( -
+
{indices.map((index) => ( -
- {index.name} - {fmtNumber(index.price)} - {fmtPct(index.change_pct, 2, true)} -
+
+ {index.name} + {fmtNumber(index.price)} + {fmtPct(index.change_pct, 2, true)} +
))}
); } -function EmptyState({ watchlist, onSelect }: { watchlist: WatchlistResponse; onSelect: (symbol: string) => void }) { +function EmptyOverviewState({ watchlist, onSelectTicker }: { watchlist: WatchlistResponse; onSelectTicker: (symbol: string) => void }) { return ( -
-

Overview

+
+
Overview
+

Choose a ticker to enter the workbench.

+

Search from the top bar or jump into one of your saved symbols from the sidebar watchlist.

{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.

- )} +
+ {watchlist.items.map((item) => ( + + ))} +
+ ) : null}
); } +function LoadingOverviewState({ symbol }: { symbol: string }) { + return ( +
+
+
Loading
+

{symbol}

+

Fetching quote, profile, signals, and supporting metrics.

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

{symbol}

-

This ticker could not be resolved to usable market data.

-
); } -function Stat({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) { +function ErrorOverviewState({ message }: { message: string }) { return ( -
- {label} - {missing ? "Unavailable" : value} -
+
+ Data Error +

Overview unavailable

+

{message}

+
); } -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; +function SignalCard({ overview }: { overview: TickerOverview }) { return ( -
-
52 Week Range
- {pct === null ? ( -
Range unavailable
- ) : ( - <> -
- {fmtCurrency(low)} - {fmtCurrency(price)} - {fmtCurrency(high)} -
-
- - -
- - )} -
+
+
+
+
Signals
+

Readthrough

+
+
+
+ {overview.signals.map((signal) => ( +
+ {signal.key} + {signal.value} + {signal.description} +
+ ))} +
+
); } -function ShortInterest({ overview }: { overview: TickerOverview }) { - const short = overview.short_interest; +function DataStatusCard({ overview, missingFields }: { overview: TickerOverview; missingFields: string[] }) { + const entries = Object.entries(overview.meta.sources).slice(0, 6); + return ( -
-
Short Interest
-
- - - - +
+
+
+
Data Quality
+

Coverage

+
+ {overview.meta.status}
-
+

{availableFieldSummary(overview)}

+ {overview.meta.is_partial ? ( +
+ {missingFields.length ? missingFields.slice(0, 8).map((field) => {field}) : null} +
+ ) : null} +
+ {entries.length ? ( + entries.map(([field, source]) => ( +
+ {field} + {source} +
+ )) + ) : ( +
+ Sources + Unavailable +
+ )} +
+
); } function ProfileCard({ overview }: { overview: TickerOverview }) { return ( -
-
Company Profile
-
+
+
- Sector - {overview.profile.sector || "Unavailable"} +
Company Profile
+

Context

-
- Industry - {overview.profile.industry || "Unavailable"} +
+
+
+ Sector + {overview.profile.sector || "Unavailable"} +
+
+ Industry + {overview.profile.industry || "Unavailable"} +
+
+ Exchange + {overview.profile.exchange || "Unavailable"}
+
+ Website + + {overview.profile.website ? ( + + {overview.profile.website} + + ) : ( + "Unavailable" + )} + +
+
+

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

+
+ ); +} + +function ShortInterestCard({ overview }: { overview: TickerOverview }) { + const short = overview.short_interest; + return ( +
+
- Website - {overview.profile.website ? ( - - {overview.profile.website} - - ) : ( - Unavailable - )} +
Short Interest
+

Pressure

-

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

-
+
+ + + + +
+
); } -function SourceCard({ overview }: { overview: TickerOverview }) { - const fields = Object.entries(overview.meta.sources).slice(0, 6); +function StatsCard({ overview }: { overview: TickerOverview }) { return ( -
-
Data Quality
-
- Status {overview.meta.status} +
+
+
+
Overview Stats
+

Reference

+
-
- {fields.length ? ( - fields.map(([field, source]) => ( -
- {field} - {source} -
- )) - ) : ( -
- Sources - Unavailable -
- )} +
+ + + + + +
+
+ ); +} + +function DetailItem({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) { + return ( +
+ {label} + {missing ? "Unavailable" : value} +
+ ); +} + +function StatRow({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) { + return ( +
+ {label} + {missing ? "Unavailable" : value}
); } -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; +function LoadingShell() { + return ( +
Prism
Loading
} + topbar={
Loading Prism…
} + > +
+
+
+
+
+ ); } -- cgit v1.3-2-g0d8e