// row.jsx — one ticker per table row, plus the shared column registry. // // The table is column-driven: COLUMNS is the registry of value columns, each // knowing how to render its own . app.jsx owns the *order* (drag-and-drop, // persisted) and renders the matching ; StockRow renders the cells in // that same order. The Symbol column (left) is fixed — only the value columns // to its right reorder. Deletion lives in the drawer, not the row. // ── Column registry ────────────────────────────────────────────────────── // Each cell(ctx) returns a complete . ctx = { quote, d, direction, r } // where d = resolveDisplayQuote(quote) and r = quote.returns. const COLUMNS = [ { id: "lastMarketPrice", label: "Last Price", // The last regular-session price, with a Market / Market closed badge so // you can tell at a glance whether this number is live or a frozen close. // While the regular session is open this equals Current Price; once it // closes, Current Price moves on to post/pre and this one stays put. cell: ({ quote }) => { const live = quote.marketState === "REGULAR"; return (
{formatPrice(quote.regularMarketPrice, quote.currency)}
{live ? "Market" : "Market closed"}
); }, }, { id: "currentPrice", label: "Current Price", // The active-session price (pre / market / post / closed) — carries the // session dot + label, since this is the column whose session varies. cell: ({ quote, d, direction }) => { const sessionKey = d.sessionLabel.toLowerCase().replace(/\s/g, "-"); return (
{formatPrice(d.displayPrice, quote.currency)}
{d.sessionLabel} {d.secondaryLabel && ( · {d.secondaryLabel} )}
); }, }, { id: "delta", label: "Δ", cell: ({ quote, d, direction }) => ( {formatChange(d.displayChange, quote.currency)} ), }, // 1D tracks the active session — regular % while the market is open, then // post/pre % once it closes — so it keeps moving when the bands are frozen. { id: "r1d", label: "1D", cell: ({ d }) => }, { id: "r1m", label: "1M", cell: ({ r }) => }, { id: "r6m", label: "6M", cell: ({ r }) => }, { id: "rytd", label: "YTD", cell: ({ r }) => }, { id: "r1y", label: "1Y", cell: ({ r }) => }, { id: "afterHoursVol", label: "After Hrs Vol", // Extended-hours share volume — "n/a" while the regular session is open // (there's no after-hours figure yet), otherwise the live pre/post volume: // a real number, "0" for covered-but-untraded, or "—" for no data. cell: ({ quote }) => ( {quote.marketState === "REGULAR" ? "n/a" : formatCompact(quote.afterHoursVolume)} ), }, { id: "volume", label: "Volume", cell: ({ quote }) => ( {quote.volume || "—"} ), }, { id: "avgVol3m", label: "Avg Vol 3M", cell: ({ quote }) => ( {quote.averageVolume3M || quote.averageVolume || "—"} ), }, { id: "marketCap", label: "Market cap", cell: ({ quote }) => ( {quote.marketCap || "—"} ), }, ]; const COLUMN_BY_ID = Object.fromEntries(COLUMNS.map((c) => [c.id, c])); const DEFAULT_COLUMN_ORDER = COLUMNS.map((c) => c.id); // Resolve a persisted order against the live registry: keep known ids in the // saved order, drop unknown ones, append any columns added since. function normalizeColumnOrder(saved) { if (!Array.isArray(saved)) return [...DEFAULT_COLUMN_ORDER]; const known = saved.filter((id) => COLUMN_BY_ID[id]); const missing = DEFAULT_COLUMN_ORDER.filter((id) => !known.includes(id)); return known.length ? [...known, ...missing] : [...DEFAULT_COLUMN_ORDER]; } // ── StockRow ───────────────────────────────────────────────────────────── // The whole is a pointer-drag handle (App owns the reorder logic) and a // click target — App decides click-vs-drag from the pointer movement, then // either opens the drawer or commits a reorder. `style` / `extraClass` carry // the live drag transform; deletion lives in the drawer now, not the row. function StockRow({ ticker, columnOrder, onOpen, onPointerDown, style, extraClass }) { const [quote, setQuote] = React.useState(null); const [error, setError] = React.useState(null); const [flashing, setFlashing] = React.useState(false); const prevPriceRef = React.useRef(null); React.useEffect(() => { let unsubscribe; let cancelled = false; fetchQuote(ticker) .then((q) => { if (cancelled) return; setQuote({ ...q }); prevPriceRef.current = resolveDisplayQuote(q).displayPrice; unsubscribe = subscribeToQuoteUpdates(ticker, (next) => { const d = resolveDisplayQuote(next); const prev = prevPriceRef.current; if (prev != null && d.displayPrice !== prev) { setFlashing(true); setTimeout(() => setFlashing(false), 700); } prevPriceRef.current = d.displayPrice; setQuote({ ...next }); }); }) .catch((e) => !cancelled && setError(e.message)); return () => { cancelled = true; if (unsubscribe) unsubscribe(); }; }, [ticker]); const span = columnOrder.length; if (error) { return ( onPointerDown(e, ticker)}>
{ticker}
Couldn't load
{error} ); } if (!quote) { return ( onPointerDown(e, ticker)}>
{ticker}
 
Loading… ); } const d = resolveDisplayQuote(quote); const direction = d.displayChange > 0 ? "up" : d.displayChange < 0 ? "down" : "flat"; const r = quote.returns || {}; const ctx = { quote, d, direction, r }; return ( onPointerDown(e, ticker)} role="button" tabIndex="0" onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onOpen()}>
{quote.ticker} {quote.exchange}
{quote.name}
{columnOrder.map((id) => { const col = COLUMN_BY_ID[id]; return col ? React.cloneElement(col.cell(ctx), { key: id }) : null; })} ); } function ReturnCell({ value }) { if (value == null || Number.isNaN(value)) { return —; } const dir = value > 0 ? "up" : value < 0 ? "down" : "flat"; const sign = value > 0 ? "+" : value < 0 ? "−" : ""; return ( {sign + Math.abs(value).toFixed(2) + "%"} ); } window.StockRow = StockRow; window.COLUMNS = COLUMNS; window.COLUMN_BY_ID = COLUMN_BY_ID; window.DEFAULT_COLUMN_ORDER = DEFAULT_COLUMN_ORDER; window.normalizeColumnOrder = normalizeColumnOrder;