// groups.jsx — the board: an "Ungrouped" inbox plus titled grouping cards. // // The board model is a flat object: // { ungrouped: string[], cards: [{ id, title, tickers: string[] }] } // New tickers land in `ungrouped`; the analyst drags rows into cards (and // between cards, and back). Every container — the inbox and each card — renders // the same LedgerTable, so the columns line up across the whole board. // // Row drag is cross-container and pointer-driven (App owns the state). While a // drag is live the lifted ticker is removed from its list and a placeholder gap // is shown wherever it would drop; a position:fixed clone follows the pointer. // ── Board model ────────────────────────────────────────────────────────── const DEFAULT_TICKERS = [ "AAPL", "MSFT", "NVDA", "TSLA", "SAP.DE", "ASML.AS", "NOVO-B.CO", "9988.HK", ]; function loadBoard() { try { const raw = localStorage.getItem("lsm.board.v1"); if (raw) { const b = JSON.parse(raw); if (b && Array.isArray(b.ungrouped) && Array.isArray(b.cards)) return b; } } catch (e) {} // Migrate the legacy flat watchlist into the inbox. try { const legacy = localStorage.getItem("lsm.tickers"); if (legacy) { const arr = JSON.parse(legacy); if (Array.isArray(arr)) return { ungrouped: arr, cards: [] }; } } catch (e) {} return { ungrouped: [...DEFAULT_TICKERS], cards: [] }; } function saveBoard(board) { try { localStorage.setItem("lsm.board.v1", JSON.stringify(board)); } catch (e) {} } // Every ticker on the board, in display order (inbox first, then each card). function boardAllTickers(board) { return [board.ungrouped, ...board.cards.map((c) => c.tickers)].reduce( (acc, list) => acc.concat(list), [] ); } function boardHasTicker(board, ticker) { return boardAllTickers(board).includes(ticker); } // The ticker list for one container id ("ungrouped" or a card id). function containerTickers(board, containerId) { if (containerId === "ungrouped") return board.ungrouped; const card = board.cards.find((c) => c.id === containerId); return card ? card.tickers : []; } // ── Board transforms — each returns a new board ────────────────────────── function boardAddTicker(board, ticker) { return { ...board, ungrouped: [...board.ungrouped, ticker] }; } function boardRemoveTicker(board, ticker) { return { ungrouped: board.ungrouped.filter((t) => t !== ticker), cards: board.cards.map((c) => ({ ...c, tickers: c.tickers.filter((t) => t !== ticker), })), }; } function clampIndex(i, len) { if (i == null || i < 0) return len; return Math.min(i, len); } // Move `ticker` to `toContainer` at `toIndex`, removing it from wherever it is. function boardMoveTicker(board, ticker, toContainer, toIndex) { const stripped = boardRemoveTicker(board, ticker); if (toContainer === "ungrouped") { const next = [...stripped.ungrouped]; next.splice(clampIndex(toIndex, next.length), 0, ticker); return { ...stripped, ungrouped: next }; } return { ...stripped, cards: stripped.cards.map((c) => { if (c.id !== toContainer) return c; const next = [...c.tickers]; next.splice(clampIndex(toIndex, next.length), 0, ticker); return { ...c, tickers: next }; }), }; } function boardCreateCard(board) { const card = { id: "g" + Date.now().toString(36), title: "", tickers: [] }; return { ...board, cards: [...board.cards, card] }; } function boardRenameCard(board, id, title) { return { ...board, cards: board.cards.map((c) => (c.id === id ? { ...c, title } : c)), }; } // Delete a card — its tickers fall back into the inbox so nothing is lost. function boardDeleteCard(board, id) { const card = board.cards.find((c) => c.id === id); if (!card) return board; return { ungrouped: [...board.ungrouped, ...card.tickers], cards: board.cards.filter((c) => c.id !== id), }; } // Move a card to a new index in the card list (drag-reorder). function boardMoveCard(board, cardId, toIndex) { const card = board.cards.find((c) => c.id === cardId); if (!card) return board; const next = board.cards.filter((c) => c.id !== cardId); next.splice(clampIndex(toIndex, next.length), 0, card); return { ...board, cards: next }; } // Per-card transform during a card drag — the lifted card tracks the pointer, // the cards it has crossed slide one slot to open a gap ("the rail"). `slot` is // the dragged card's height plus the inter-card gap. function cardDragVisual(cardId, index, cardDrag) { if (!cardDrag || !cardDrag.armed) return { style: undefined, extraClass: "" }; const { fromIndex, overIndex, offsetY, slot, moved } = cardDrag; if (cardId === cardDrag.cardId) { return { style: { transform: "translateY(" + offsetY + "px)", transition: "none", position: "relative", zIndex: 40, }, extraClass: " group-card--lifted", }; } let shift = 0; if (moved) { if (overIndex > fromIndex && index > fromIndex && index <= overIndex) { shift = -slot; } else if (overIndex < fromIndex && index >= overIndex && index < fromIndex) { shift = slot; } } return { style: { transform: shift ? "translateY(" + shift + "px)" : "none", transition: "transform .18s cubic-bezier(.2,.8,.2,1)", }, extraClass: "", }; } // ── LedgerHead — the draggable column-header row, shared by every table ── function LedgerHead({ columnOrder, dragId, dropId, setDragId, setDropId, moveColumn }) { return ( Symbol {columnOrder.map((id) => { const col = COLUMN_BY_ID[id]; if (!col) return null; const cls = "ld__h-num ld__h-drag ld__h-" + id + (dragId === id ? " ld__h--dragging" : "") + (dropId === id && dragId && dragId !== id ? " ld__h--drop" : ""); return ( setDragId(id)} onDragOver={(e) => { e.preventDefault(); if (dropId !== id) setDropId(id); }} onDragLeave={() => setDropId((d) => (d === id ? null : d))} onDrop={(e) => { e.preventDefault(); if (dragId) moveColumn(dragId, id); setDragId(null); setDropId(null); }} onDragEnd={() => { setDragId(null); setDropId(null); }}> {col.label} ); })} ); } // ── LedgerTable — one container's table (the inbox or a card) ──────────── function LedgerTable({ containerId, tickers, emptyHint, columnOrder, rowDrag, onRowPointerDown, onOpenTicker, dragId, dropId, setDragId, setDropId, moveColumn, }) { const span = columnOrder.length + 1; // Mid-drag the lifted ticker is pulled out of its list; a placeholder shows // where it would land in whichever container the pointer is over. const dragging = rowDrag && rowDrag.active; const rows = dragging ? tickers.filter((t) => t !== rowDrag.ticker) : tickers; const placeholderAt = dragging && rowDrag.overContainer === containerId ? Math.min(rowDrag.overIndex, rows.length) : -1; return ( {rows.length === 0 && placeholderAt < 0 && ( )} {rows.map((ticker, i) => ( {i === placeholderAt && ( )} onRowPointerDown(e, tk, containerId)} onOpen={() => onOpenTicker(ticker)} /> ))} {placeholderAt >= rows.length && ( )}
{emptyHint}
); } // ── GroupCard — a titled container wrapping a LedgerTable ──────────────── function GroupCard({ card, index, cardDrag, onCardPointerDown, columnOrder, rowDrag, onRowPointerDown, onOpenTicker, onRename, onDelete, dragId, dropId, setDragId, setDropId, moveColumn, }) { const [confirming, setConfirming] = React.useState(false); const isOver = rowDrag && rowDrag.active && rowDrag.overContainer === card.id; const v = cardDragVisual(card.id, index, cardDrag); return (
onCardPointerDown(e, card.id)}> onRename(card.id, e.target.value)} /> {card.tickers.length} {card.tickers.length === 1 ? " company" : " companies"} {!confirming ? ( ) : ( e.stopPropagation()}> Delete this group? )}
); } // ── RowDragClone — the position:fixed row that follows the pointer ─────── // A one-row table; carries the same .ld__h-* width classes as the // real headers, so table-layout:fixed lays the clone out identically. function RowDragClone({ rowDrag, columnOrder }) { const base = { width: rowDrag.rowWidth }; const style = rowDrag.settling ? { ...base, left: rowDrag.settleX, top: rowDrag.settleY, transition: "left .16s ease, top .16s ease", } : { ...base, left: rowDrag.pointerX - rowDrag.grabDX, top: rowDrag.pointerY - rowDrag.grabDY, }; return (
{columnOrder.map((id) => )} {}} onOpen={() => {}} />
); } // Hit-test: which container + insertion index is the pointer over? Returns // null when the pointer isn't over any container. Pure DOM read — every // LedgerTable's carries data-container-id, so no ref plumbing. function hitTestContainers(x, y) { const tbodies = document.querySelectorAll("tbody[data-container-id]"); for (let t = 0; t < tbodies.length; t++) { const tbody = tbodies[t]; const r = tbody.getBoundingClientRect(); if (y < r.top - 28 || y > r.bottom + 28) continue; if (x < r.left - 48 || x > r.right + 48) continue; const rows = tbody.querySelectorAll("tr.ld__row:not(.ld__row--placeholder)"); let index = rows.length; for (let i = 0; i < rows.length; i++) { const rr = rows[i].getBoundingClientRect(); if (y < rr.top + rr.height / 2) { index = i; break; } } return { containerId: tbody.dataset.containerId, index }; } return null; } Object.assign(window, { DEFAULT_TICKERS, loadBoard, saveBoard, boardAllTickers, boardHasTicker, containerTickers, boardAddTicker, boardRemoveTicker, boardMoveTicker, boardCreateCard, boardRenameCard, boardDeleteCard, boardMoveCard, cardDragVisual, LedgerTable, GroupCard, RowDragClone, hitTestContainers, });