diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-17 13:07:40 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-17 13:07:40 -0700 |
| commit | 62bdd79b3473262dde5fb0a90eab34fe7bf344fd (patch) | |
| tree | 84f75baf7503e1df77c8335750650a72b088468a /frontend/components | |
| parent | 1482422f2f5b236cdcdff4429ae06bb55dca4083 (diff) | |
'UI Shell and General Architecture'
Diffstat (limited to 'frontend/components')
| -rw-r--r-- | frontend/components/PriceChart.tsx | 31 | ||||
| -rw-r--r-- | frontend/components/prism/AppShell.tsx | 20 | ||||
| -rw-r--r-- | frontend/components/prism/ChartCard.tsx | 55 | ||||
| -rw-r--r-- | frontend/components/prism/KPIStrip.tsx | 16 | ||||
| -rw-r--r-- | frontend/components/prism/Sidebar.tsx | 101 | ||||
| -rw-r--r-- | frontend/components/prism/TickerHeader.tsx | 51 | ||||
| -rw-r--r-- | frontend/components/prism/TopBar.tsx | 61 |
7 files changed, 326 insertions, 9 deletions
diff --git a/frontend/components/PriceChart.tsx b/frontend/components/PriceChart.tsx index 8ce4ea9..3db55ce 100644 --- a/frontend/components/PriceChart.tsx +++ b/frontend/components/PriceChart.tsx @@ -12,6 +12,10 @@ type Props = { }; export function PriceChart({ symbol, points }: Props) { + if (!points.length) { + return <div className="psm-card-empty">No price history available for this range.</div>; + } + const x = points.map((point) => point.date); const y = points.map((point) => point.close ?? null); const data: Data[] = [ @@ -21,26 +25,35 @@ export function PriceChart({ symbol, points }: Props) { type: "scatter", mode: "lines", name: symbol, - line: { color: "#C2AA7A", width: 2.4 }, + line: { color: "#C2AA7A", width: 2.5 }, fill: "tozeroy", - fillcolor: "rgba(194,170,122,0.10)" + fillcolor: "rgba(194,170,122,0.08)", + hovertemplate: "%{x}<br>$%{y:.2f}<extra></extra>" } ]; + const layout: Partial<Layout> = { autosize: true, height: 360, - margin: { l: 52, r: 18, t: 18, b: 38 }, + margin: { l: 52, r: 24, t: 20, b: 42 }, paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", hovermode: "x unified", font: { family: "IBM Plex Mono, monospace", color: "#8E8676" }, - xaxis: { showgrid: false, zeroline: false }, - yaxis: { showgrid: true, gridcolor: "#232934", tickprefix: "$", tickformat: ",.2f" } + xaxis: { + showgrid: false, + zeroline: false, + color: "#8E8676" + }, + yaxis: { + showgrid: true, + gridcolor: "#232934", + color: "#8E8676", + tickprefix: "$", + tickformat: ",.2f" + } }; - if (!points.length) { - return <div className="empty-panel">No price history available for this period.</div>; - } - return <Plot data={data} layout={layout} config={{ displayModeBar: false, responsive: true }} className="chart" useResizeHandler />; } + diff --git a/frontend/components/prism/AppShell.tsx b/frontend/components/prism/AppShell.tsx new file mode 100644 index 0000000..63fd62b --- /dev/null +++ b/frontend/components/prism/AppShell.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +type Props = { + sidebar: ReactNode; + topbar: ReactNode; + children: ReactNode; +}; + +export function AppShell({ sidebar, topbar, children }: Props) { + return ( + <main className="prism-app"> + {sidebar} + <div className="psm-main"> + {topbar} + <section className="psm-content">{children}</section> + </div> + </main> + ); +} + diff --git a/frontend/components/prism/ChartCard.tsx b/frontend/components/prism/ChartCard.tsx new file mode 100644 index 0000000..bc650d7 --- /dev/null +++ b/frontend/components/prism/ChartCard.tsx @@ -0,0 +1,55 @@ +import { PriceChart } from "@/components/PriceChart"; +import type { HistoryPoint } 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 Props = { + symbol: string; + period: string; + points: HistoryPoint[]; + chartState: "idle" | "loading" | "ready" | "error"; + chartError: string | null; + onChangePeriod: (period: string) => void; +}; + +export function ChartCard({ symbol, period, points, chartState, chartError, onChangePeriod }: Props) { + return ( + <section className="psm-card"> + <div className="psm-card-head"> + <div> + <div className="psm-eyebrow">Price History</div> + <h2 className="psm-card-title">{symbol}</h2> + </div> + <div className="psm-tabs" role="tablist" aria-label="Chart range"> + {PERIODS.map((option) => ( + <button + key={option.key} + type="button" + role="tab" + className={`psm-tab${period === option.key ? " active" : ""}`} + aria-selected={period === option.key} + onClick={() => onChangePeriod(option.key)} + > + {option.label} + </button> + ))} + </div> + </div> + + <p className="psm-chart-meta">Chart loading is isolated from the rest of Overview. A history miss only affects this card.</p> + + <div className="psm-chart-frame"> + {chartState === "loading" ? <div className="psm-card-empty">Loading {period.toUpperCase()} history…</div> : null} + {chartState === "error" ? <div className="psm-card-empty psm-error-copy">{chartError || "Could not load chart history."}</div> : null} + {chartState === "ready" ? <PriceChart symbol={symbol} points={points} /> : null} + </div> + </section> + ); +} + diff --git a/frontend/components/prism/KPIStrip.tsx b/frontend/components/prism/KPIStrip.tsx new file mode 100644 index 0000000..b3cd34c --- /dev/null +++ b/frontend/components/prism/KPIStrip.tsx @@ -0,0 +1,16 @@ +import type { KpiItem } from "@/lib/overview"; + +export function KPIStrip({ items }: { items: KpiItem[] }) { + return ( + <section className="psm-card psm-kpis" aria-label="Key metrics"> + {items.map((item) => ( + <div className="psm-kpi" key={item.key}> + <span className="psm-kpi-key">{item.key}</span> + <span className={`psm-kpi-value${item.missing ? " missing" : ""}`}>{item.value}</span> + <span className="psm-kpi-sub">{item.sublabel}</span> + </div> + ))} + </section> + ); +} + diff --git a/frontend/components/prism/Sidebar.tsx b/frontend/components/prism/Sidebar.tsx new file mode 100644 index 0000000..15a2947 --- /dev/null +++ b/frontend/components/prism/Sidebar.tsx @@ -0,0 +1,101 @@ +import Image from "next/image"; +import type { NavItem } from "@/lib/overview"; +import { deltaClass, fmtCurrency, fmtPct } from "@/lib/format"; +import { watchlistSubtitle } from "@/lib/overview"; +import type { WatchlistResponse } from "@/types/api"; + +type Props = { + navItems: NavItem[]; + selectedKey: string; + currentTicker: string; + watchlist: WatchlistResponse; + watchlistError: string | null; + onSelectTicker: (symbol: string) => void; + onRemoveTicker: (symbol: string) => void; +}; + +export function Sidebar({ + navItems, + selectedKey, + currentTicker, + watchlist, + watchlistError, + onSelectTicker, + onRemoveTicker +}: Props) { + return ( + <aside className="psm-side"> + <div className="psm-brand"> + <Image className="psm-brand-mark" src="/design-system/logo-monogram.svg" alt="" width={34} height={34} /> + <div className="psm-brand-copy"> + <div className="psm-brand-name">Prism</div> + <div className="psm-brand-sub">Market Workbench</div> + </div> + </div> + + <div className="psm-side-section"> + <div className="psm-side-label">Workspace</div> + </div> + + <nav className="psm-nav" aria-label="Primary"> + {navItems.map((item) => { + const active = item.key === selectedKey; + return ( + <button + key={item.key} + type="button" + className={`psm-nav-item${active ? " active" : ""}${item.disabled ? " disabled" : ""}`} + aria-disabled={item.disabled ? "true" : undefined} + > + <span className={`psm-icon icon-${item.icon}`} aria-hidden /> + <span className="psm-nav-copy"> + <span>{item.label}</span> + {item.disabled ? <span className="psm-nav-coming">Soon</span> : null} + </span> + </button> + ); + })} + </nav> + + <div className="psm-side-section"> + <div className="psm-side-label">Watchlist</div> + </div> + + <div className="psm-watch"> + <div className="psm-watch-toolbar"> + <div className="psm-watch-limit"> + {watchlist.items.length}/{watchlist.limit} + </div> + </div> + + {watchlist.items.length === 0 ? <div className="psm-watch-empty">Saved tickers will appear here.</div> : null} + + {watchlist.items.map((item) => { + const active = item.symbol === currentTicker; + return ( + <div key={item.symbol} className={`psm-watch-row${active ? " active" : ""}`}> + <button type="button" className="psm-watch-select" onClick={() => onSelectTicker(item.symbol)}> + <span className="psm-watch-main"> + <span className="psm-watch-symbol">{item.symbol}</span> + <span className="psm-watch-date">{watchlistSubtitle(item)}</span> + </span> + <span className="psm-watch-price">{fmtCurrency(item.quote?.price)}</span> + <span className={`psm-watch-change ${deltaClass(item.quote?.change_pct)}`}>{fmtPct(item.quote?.change_pct, 2, true)}</span> + </button> + <button + type="button" + aria-label={`Remove ${item.symbol} from watchlist`} + className="psm-watch-remove" + onClick={() => onRemoveTicker(item.symbol)} + > + × + </button> + </div> + ); + })} + + {watchlistError ? <p className="psm-muted-copy psm-error-copy">{watchlistError}</p> : null} + </div> + </aside> + ); +} diff --git a/frontend/components/prism/TickerHeader.tsx b/frontend/components/prism/TickerHeader.tsx new file mode 100644 index 0000000..23254f8 --- /dev/null +++ b/frontend/components/prism/TickerHeader.tsx @@ -0,0 +1,51 @@ +import { deltaClass, fmtCurrency, fmtPct } from "@/lib/format"; +import { buildIdentityLine, rangePercent } from "@/lib/overview"; +import type { TickerOverview } from "@/types/api"; + +type Props = { + overview: TickerOverview; + onToggleWatchlist: () => void; + isSaved: boolean; +}; + +export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) { + const pct = rangePercent(overview); + + return ( + <header className="psm-ticker-head"> + <div className="psm-header-left"> + <span className="psm-identity-line psm-eyebrow">{overview.profile.symbol}</span> + <div className="psm-heading-row"> + <span className="psm-symbol">{overview.profile.symbol}</span> + <span className="psm-company-name">{overview.profile.name || "Name unavailable"}</span> + {overview.meta.is_partial ? <span className="psm-partial-chip">Partial Data</span> : null} + </div> + <p className="psm-subline">{buildIdentityLine(overview)}</p> + </div> + + <div className="psm-range"> + <div className="psm-eyebrow">52 Week Range</div> + <div className="psm-range-values"> + <span>{fmtCurrency(overview.range_52w.low)}</span> + <span>{fmtCurrency(overview.range_52w.price ?? overview.quote.price)}</span> + <span>{fmtCurrency(overview.range_52w.high)}</span> + </div> + <div className="psm-range-rail" aria-hidden> + {pct != null ? <span className="psm-range-indicator" style={{ left: `${pct}%` }} /> : null} + </div> + </div> + + <div className="psm-price-stack"> + <span className="psm-price">{fmtCurrency(overview.quote.price)}</span> + <span className={`psm-change ${deltaClass(overview.quote.change_pct)}`}> + {fmtCurrency(overview.quote.change)} · {fmtPct(overview.quote.change_pct, 2, true)} + </span> + <span className="psm-quote-line">Prev close {fmtCurrency(overview.quote.prev_close)}</span> + <button type="button" className={`psm-primary-action${isSaved ? " subtle" : ""}`} onClick={onToggleWatchlist}> + {isSaved ? "Remove From Watchlist" : "Save To Watchlist"} + </button> + </div> + </header> + ); +} + diff --git a/frontend/components/prism/TopBar.tsx b/frontend/components/prism/TopBar.tsx new file mode 100644 index 0000000..38e8447 --- /dev/null +++ b/frontend/components/prism/TopBar.tsx @@ -0,0 +1,61 @@ +import type { FormEvent } from "react"; +import type { SearchResult } from "@/types/api"; + +type Props = { + query: string; + searching: boolean; + results: SearchResult[]; + marketStatus: { isOpen: boolean; status: string; time: string }; + onChangeQuery: (value: string) => void; + onSubmit: (event: FormEvent<HTMLFormElement>) => void; + onSelectTicker: (symbol: string) => void; +}; + +export function TopBar({ query, searching, results, marketStatus, onChangeQuery, onSubmit, onSelectTicker }: Props) { + const showDropdown = query.trim().length >= 2; + + return ( + <header className="psm-top"> + <div className="psm-search-shell"> + <form className="psm-search-form" onSubmit={onSubmit}> + <span className="psm-icon icon-search" aria-hidden /> + <input value={query} onChange={(event) => onChangeQuery(event.target.value)} placeholder="Search ticker or company" aria-label="Search ticker" /> + <span className="psm-kbd">Enter</span> + </form> + + {showDropdown ? ( + <div className="psm-search-dropdown"> + {searching ? <div className="psm-search-status">Searching...</div> : null} + {!searching && results.length === 0 ? <div className="psm-search-status">No matches</div> : null} + {!searching + ? results.map((result) => ( + <button key={`${result.symbol}-${result.exchange}`} type="button" className="psm-search-result" onClick={() => onSelectTicker(result.symbol)}> + <span className="psm-search-result-symbol">{result.symbol}</span> + <span className="psm-search-result-copy"> + {result.name} + {result.exchange ? ` · ${result.exchange}` : ""} + </span> + </button> + )) + : null} + </div> + ) : null} + </div> + + <div className="psm-clock-group"> + <div className="psm-market-status"> + <span className={`psm-market-dot${marketStatus.isOpen ? " open" : ""}`} aria-hidden /> + <span>{marketStatus.status}</span> + <span>{marketStatus.time}</span> + </div> + + <div className="psm-account"> + <span className="psm-icon icon-user" aria-hidden /> + <span className="psm-account-avatar">T</span> + <span>Local Profile</span> + </div> + </div> + </header> + ); +} + |
