// 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)}>
|
|
{error} |
);
}
if (!quote) {
return (
onPointerDown(e, 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;
|