// app.jsx — root composition: header, the board (Ungrouped inbox + grouping // cards), drawer, tweaks. Owns the board, the global column order, and the // cross-container row drag. The board model + LedgerTable / GroupCard live in // groups.jsx; the per-ticker row lives in row.jsx. function App({ onLogout }) { const [t, setTweak] = useTweaks(window.TWEAK_DEFAULTS); // The board + column order live server-side now (one JSON blob per user). // null = still loading from /board. const [board, setBoard] = React.useState(null); const [saveState, setSaveState] = React.useState("idle"); // idle|saving|saved|error const [migrateOffer, setMigrateOffer] = React.useState(null); // pending local import const [input, setInput] = React.useState(""); const [error, setError] = React.useState(""); const [openTicker, setOpenTicker] = React.useState(null); const [columnOrder, setColumnOrder] = React.useState(() => [...DEFAULT_COLUMN_ORDER]); const [dragId, setDragId] = React.useState(null); // column-header drag const [dropId, setDropId] = React.useState(null); // column-header drag // Cross-container row drag — see the pointer handlers + RowDragClone. const [rowDrag, setRowDrag] = React.useState(null); const rowDragRef = React.useRef(null); rowDragRef.current = rowDrag; // Card reorder — press-and-hold a card header, then drag it up/down. const [cardDrag, setCardDrag] = React.useState(null); const cardDragRef = React.useRef(null); cardDragRef.current = cardDrag; const inputRef = React.useRef(null); // ── Server-backed board ──────────────────────────────────────────────── // Load once on mount. boardLoadedRef gates the persist effect so the very // first setBoard (from the server payload) doesn't immediately PUT it back. const boardLoadedRef = React.useRef(false); React.useEffect(() => { fetchBoard() .then((b) => { setBoard({ ungrouped: b.ungrouped || [], cards: b.cards || [] }); if (Array.isArray(b.columnOrder) && b.columnOrder.length) { setColumnOrder(normalizeColumnOrder(b.columnOrder)); } boardLoadedRef.current = true; // One-time offer to import a dashboard left in this browser's // localStorage — only when the server board is still the default seed. try { if (!localStorage.getItem("lsm.migrated.v1")) { const local = loadBoard(); const serverIsDefault = (!b.cards || b.cards.length === 0) && JSON.stringify(b.ungrouped || []) === JSON.stringify(DEFAULT_TICKERS); const localIsDefault = (!local.cards || local.cards.length === 0) && JSON.stringify(local.ungrouped || []) === JSON.stringify(DEFAULT_TICKERS); const localHasContent = (local.ungrouped && local.ungrouped.length) || (local.cards && local.cards.length); if (serverIsDefault && localHasContent && !localIsDefault) { setMigrateOffer(local); } else { localStorage.setItem("lsm.migrated.v1", "nothing-to-import"); } } } catch (e) {} }) .catch(() => { // /board 401 — the session lapsed. Bounce back to the login gate. if (onLogout) onLogout(); }); }, []); // Persist the board + column order, debounced — not on every keystroke. A // failed PUT isn't swallowed: it flips the footer to "Save failed" and keeps // retrying every 4s until it lands (or the next edit supersedes it). const saveTimer = React.useRef(null); React.useEffect(() => { if (!boardLoadedRef.current || board == null) return; const blob = { ungrouped: board.ungrouped, cards: board.cards, columnOrder: columnOrder, }; if (saveTimer.current) clearTimeout(saveTimer.current); setSaveState("saving"); const attempt = () => { saveBoardToServer(blob) .then(() => setSaveState("saved")) .catch(() => { setSaveState("error"); saveTimer.current = setTimeout(attempt, 4000); }); }; saveTimer.current = setTimeout(attempt, 600); return () => saveTimer.current && clearTimeout(saveTimer.current); }, [board, columnOrder]); // Flush any pending save when the tab is hidden or the user navigates away — // keepalive lets the PUT outlive the unload. React.useEffect(() => { const flush = () => { if (!boardLoadedRef.current || board == null) return; if (saveTimer.current) clearTimeout(saveTimer.current); flushBoard({ ungrouped: board.ungrouped, cards: board.cards, columnOrder: columnOrder, }); }; const onVis = () => { if (document.visibilityState === "hidden") flush(); }; window.addEventListener("visibilitychange", onVis); window.addEventListener("beforeunload", flush); return () => { window.removeEventListener("visibilitychange", onVis); window.removeEventListener("beforeunload", flush); }; }, [board, columnOrder]); const logout = () => { flushBoard({ ungrouped: board ? board.ungrouped : [], cards: board ? board.cards : [], columnOrder: columnOrder, }); fetch("/auth/logout", { method: "POST", credentials: "same-origin" }) .finally(() => onLogout && onLogout()); }; const acceptMigration = () => { setBoard({ ungrouped: migrateOffer.ungrouped || [], cards: migrateOffer.cards || [] }); try { localStorage.setItem("lsm.migrated.v1", "imported"); } catch (e) {} setMigrateOffer(null); }; const declineMigration = () => { try { localStorage.setItem("lsm.migrated.v1", "declined"); } catch (e) {} setMigrateOffer(null); }; // Apply tweaks via documentElement data attributes so CSS controls everything React.useEffect(() => { document.documentElement.dataset.density = t.density; document.documentElement.dataset.theme = t.night ? "graphite" : "cream"; document.documentElement.dataset.accent = t.accent; document.documentElement.dataset.pulses = t.pulses ? "on" : "off"; }, [t.density, t.night, t.accent, t.pulses]); const addTicker = (raw) => { const ticker = raw.trim().toUpperCase(); if (!ticker) return; if (!/^[A-Z0-9.\-]{1,12}$/.test(ticker)) { setError("Use letters, numbers, dot or dash (e.g. AAPL or SAP.DE)"); return; } if (boardHasTicker(board, ticker)) { setError(ticker + " is already on the board"); return; } setBoard((b) => boardAddTicker(b, ticker)); setInput(""); setError(""); }; const removeTicker = (ticker) => { setBoard((b) => boardRemoveTicker(b, ticker)); if (openTicker === ticker) setOpenTicker(null); }; // Drag a value-column header onto another to drop it into that slot. The // order is global — every table (inbox + cards) renders it. const moveColumn = (fromId, toId) => { setColumnOrder((order) => { if (fromId === toId) return order; const next = order.filter((id) => id !== fromId); const at = next.indexOf(toId); next.splice(at < 0 ? next.length : at, 0, fromId); return next; }); }; // ── Cross-container row drag ─────────────────────────────────────────── // The move/up/cancel handlers are created once (stable identity — they read // live state through rowDragRef) and bound to window *synchronously* on // pointer-down, so a click faster than a paint can't slip past a not-yet- // bound pointerup listener. const rowDragHandlers = React.useRef(null); if (!rowDragHandlers.current) { const teardown = () => { window.removeEventListener("pointermove", h.move); window.removeEventListener("pointerup", h.up); window.removeEventListener("pointercancel", h.cancel); }; const h = { move: (e) => { const d = rowDragRef.current; if (!d || d.settling) return; const dx = e.clientX - d.startX; const dy = e.clientY - d.startY; if (!d.active && Math.abs(dx) < 6 && Math.abs(dy) < 6) return; // Hit-test every container's
for the drop target + index. const hit = hitTestContainers(e.clientX, e.clientY); setRowDrag({ ...d, active: true, pointerX: e.clientX, pointerY: e.clientY, overContainer: hit ? hit.containerId : d.fromContainer, overIndex: hit ? hit.index : d.fromIndex, }); }, up: () => { const d = rowDragRef.current; if (!d || d.settling) return; teardown(); if (!d.active) { // Never crossed the threshold — a plain click. Open the drawer. setRowDrag(null); setOpenTicker(d.ticker); return; } const commit = () => { setBoard((b) => boardMoveTicker(b, d.ticker, d.overContainer, d.overIndex)); setRowDrag(null); }; // Glide the clone onto the placeholder gap, then commit — so the row // lands exactly where the clone settled, with no jump. const ph = document.querySelector(".ld__row--placeholder"); const target = ph ? ph.getBoundingClientRect() : null; if (target) { setRowDrag({ ...d, settling: true, settleX: target.left, settleY: target.top }); window.setTimeout(commit, 170); } else { commit(); } }, cancel: () => { teardown(); setRowDrag(null); }, }; rowDragHandlers.current = h; } // Pointer-down on a row arms a *potential* drag — click (open the drawer) vs // drag (reorder / regroup) is decided by how far the pointer travels. const onRowPointerDown = (e, ticker, containerId) => { if (e.button != null && e.button !== 0) return; // primary button only if (rowDragRef.current || cardDragRef.current) return; // a drag is already live const list = containerTickers(board, containerId); const fromIndex = list.indexOf(ticker); if (fromIndex < 0) return; const rect = e.currentTarget.getBoundingClientRect(); const handlers = rowDragHandlers.current; window.addEventListener("pointermove", handlers.move); window.addEventListener("pointerup", handlers.up); window.addEventListener("pointercancel", handlers.cancel); setRowDrag({ ticker, fromContainer: containerId, fromIndex, startX: e.clientX, startY: e.clientY, pointerX: e.clientX, pointerY: e.clientY, grabDX: e.clientX - rect.left, grabDY: e.clientY - rect.top, rowWidth: rect.width, rowHeight: rect.height, overContainer: containerId, overIndex: fromIndex, active: false, settling: false, }); }; // ── Card reorder (press-and-hold the header, then drag) ──────────────── // Cards carry an editable title, so a movement threshold would clash with // selecting text in it — hence a hold delay: press the header, hold ~160ms // without moving and the card lifts; then drag it to reorder. The displaced // cards slide on a rail; the order commits on release. const cardDragHandlers = React.useRef(null); if (!cardDragHandlers.current) { const teardown = () => { window.removeEventListener("pointermove", h.move); window.removeEventListener("pointerup", h.up); window.removeEventListener("pointercancel", h.cancel); }; const h = { move: (e) => { const d = cardDragRef.current; if (!d) return; if (!d.armed) { // Travelled before the hold fired — it's text selection, not a drag. if (Math.abs(e.clientY - d.startY) > 8 || Math.abs(e.clientX - d.startX) > 8) { window.clearTimeout(d.armTimer); teardown(); setCardDrag(null); } return; } if (!d.moved) { // First armed move — drop the title's focus / any text selection. if (document.activeElement && document.activeElement.blur) { document.activeElement.blur(); } const sel = window.getSelection && window.getSelection(); if (sel && sel.removeAllRanges) sel.removeAllRanges(); } const offsetY = e.clientY - d.startY; const center = d.layout[d.fromIndex].top + d.layout[d.fromIndex].height / 2 + offsetY; let over = 0; for (let i = 0; i < d.layout.length; i++) { if (i === d.fromIndex) continue; const c = d.layout[i].top + d.layout[i].height / 2; if (c < center) over++; } setCardDrag({ ...d, moved: true, offsetY, overIndex: over }); }, up: () => { const d = cardDragRef.current; if (!d) return; window.clearTimeout(d.armTimer); teardown(); if (d.armed && d.moved && d.overIndex !== d.fromIndex) { setBoard((b) => boardMoveCard(b, d.cardId, d.overIndex)); } setCardDrag(null); }, cancel: () => { const d = cardDragRef.current; if (d) window.clearTimeout(d.armTimer); teardown(); setCardDrag(null); }, }; cardDragHandlers.current = h; } const onCardPointerDown = (e, cardId) => { if (e.button != null && e.button !== 0) return; if (cardDragRef.current || rowDragRef.current) return; const fromIndex = board.cards.findIndex((c) => c.id === cardId); if (fromIndex < 0) return; // Measure every card now — drag-reorder works off fixed boundaries; the // live shifting is just feedback. const layout = []; document.querySelectorAll(".group-card").forEach((el) => { const r = el.getBoundingClientRect(); layout.push({ top: r.top, height: r.height }); }); if (!layout[fromIndex]) return; const gap = layout.length > 1 ? Math.max(0, layout[1].top - (layout[0].top + layout[0].height)) : 0; const handlers = cardDragHandlers.current; window.addEventListener("pointermove", handlers.move); window.addEventListener("pointerup", handlers.up); window.addEventListener("pointercancel", handlers.cancel); const armTimer = window.setTimeout(() => { const d = cardDragRef.current; if (!d) return; // Update the ref synchronously too, so a pointermove landing before the // re-render doesn't see a stale armed:false and abort the drag. cardDragRef.current = { ...d, armed: true }; setCardDrag(cardDragRef.current); }, 160); setCardDrag({ cardId, fromIndex, overIndex: fromIndex, startX: e.clientX, startY: e.clientY, offsetY: 0, layout, slot: layout[fromIndex].height + gap, armTimer, armed: false, moved: false, }); }; // Shared props for every LedgerTable (inbox + each card). const tableProps = { columnOrder, rowDrag, onRowPointerDown, onOpenTicker: setOpenTicker, dragId, dropId, setDragId, setDropId, moveColumn, }; // Still waiting on GET /board — keep it quiet. if (board == null) { return (A quiet watchlist for the active market, pre-market and post-market move — synchronised with each company’s own exchange.
Enter a ticker above to begin — or pick one to seed the board.