// All DOM rendering + drag/drop for the table. Talks to the outside world only through // the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender() // re-draws everything (used after structural changes like resizing seats or the chip // legend). // // The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer // badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit // next to nine others arranged in a circle at any screen size. Clicking a puck opens the // full form in the seat editor panel instead. const Render = { state: null, hooks: null, selectedSeatIndex: null, mount(state, hooks) { this.state = state; this.hooks = hooks; this.selectedSeatIndex = null; // A reset hands over a different table entirely, so nothing cached about the // last one still applies. this.lastHistorySignature = null; this.handTimeCache.clear(); this.renderAll(); }, renderAll() { if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) { this.selectedSeatIndex = null; } this.renderSeats(); this.renderSeatEditor(); this.renderChipLegend(); this.renderNotes(); this.updatePotBadge(); this.renderSetupFields(); this.renderTableHint(); this.renderStandings(); this.renderHandHistory(); if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render(); }, renderSetupFields() { document.getElementById("seatCountInput").value = this.state.seatCount; document.getElementById("directionSelect").value = this.state.direction; document.getElementById("basePotInput").value = this.state.basePotAmount ?? ""; document.getElementById("dedicatedDealerToggle").checked = !!this.state.dedicatedDealer; document.getElementById("smallBlindInput").value = this.state.smallBlind ?? ""; document.getElementById("bigBlindInput").value = this.state.bigBlind ?? ""; document.getElementById("defaultStackInput").value = this.state.defaultStack ?? ""; }, // Whether this device can actually drag a seat. HTML5 drag events are never // generated from a touch screen, so on a phone the last clause of the hint below // described something impossible — on the device the rest of the sentence was // written for. Touch gets told about the menu route instead, which works. canDragSeats() { return typeof matchMedia !== "function" || matchMedia("(hover: hover)").matches; }, renderTableHint() { const hint = document.getElementById("tableHint"); // Gestures are invisible until someone tells you about them, and this is the // only place on the table with room to say so. hint.textContent = this.canDragSeats() ? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats." : "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat."; }, renderNotes() { const notesEl = document.getElementById("tableNotes"); if (document.activeElement !== notesEl) notesEl.value = this.state.notes || ""; }, updatePotBadge() { // Chips still sitting in front of players on the current street are part of // the pot as far as anyone at the table is concerned, so count them — waiting // until the street closes would show a stale number for most of every hand. const swept = HoldemState.totalPot(this.state); const onStreet = this.state.seats.reduce((sum, p) => sum + Betting.committed(p), 0); document.getElementById("potBadgeAmount").textContent = Money.format(swept + onStreet); }, // ---- Seats around the table (compact pucks) ---- seatPosition(index, count) { return this.positionOnRing(index, count, 44, 42); }, // Bets sit on the felt between their player and the pot, the way chips actually // do — closer in than the seats, on the same spoke. The bets layer is inset to // match the seat ring (see the CSS), so this one radius holds at every size. betPosition(index, count) { return this.positionOnRing(index, count, 27, 25); }, positionOnRing(index, count, rx, ry) { const angle = -90 + (360 / count) * index; const radians = (angle * Math.PI) / 180; return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians), }; }, // Colour a bet by the biggest chip that fits it, so a table with a chip legend // gets bets that look like the chips actually being pushed out. chipColorFor(amount) { const legend = [...(this.state.chipLegend || [])] .filter(chip => Number(chip.value) > 0) .sort((a, b) => Number(b.value) - Number(a.value)); const match = legend.find(chip => amount >= Number(chip.value)); return (match || legend[legend.length - 1] || {}).color || "#8a8a8a"; }, // ---- Bets on the felt ---- makeBetChip(bet) { const chip = document.createElement("div"); chip.className = "betChip"; const disc = document.createElement("span"); disc.className = "betChipDisc"; disc.style.background = this.chipColorFor(bet); const amount = document.createElement("span"); amount.className = "betChipAmount"; amount.textContent = Money.format(bet); chip.appendChild(disc); chip.appendChild(amount); return chip; }, // A phone's felt has no room for a third ring: measured every radius between the // seats and the pot badge and each one collides with one or the other. So on a // narrow screen the bet clings to its own puck instead — same chip, no ring — // sitting on whichever side faces the middle of the table. renderBets() { const layer = document.getElementById("betsLayer"); if (!layer) return; layer.innerHTML = ""; const anchorToSeat = window.matchMedia("(max-width: 700px)").matches; this.state.seats.forEach((player, index) => { const bet = Betting.committed(player); if (bet <= 0) return; const chip = this.makeBetChip(bet); const pos = this.betPosition(index, this.state.seatCount); if (anchorToSeat) { const seat = document.querySelector(`.seat[data-seat-index="${index}"]`); if (!seat) return; const seatPos = this.seatPosition(index, this.state.seatCount); chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow"); seat.appendChild(chip); return; } chip.style.left = `${pos.left}%`; chip.style.top = `${pos.top}%`; layer.appendChild(chip); }); }, puckLabel(player, seatIndex) { return HoldemState.displayName(player, seatIndex); }, renderSeats() { const layer = document.getElementById("seatsLayer"); layer.innerHTML = ""; const template = document.getElementById("seatTemplate"); const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); this.state.seats.forEach((player, index) => { const node = template.content.firstElementChild.cloneNode(true); const pos = this.seatPosition(index, this.state.seatCount); node.style.left = `${pos.left}%`; node.style.top = `${pos.top}%`; node.dataset.seatIndex = String(index); // Which side of the seat the pot is on, which is the side its bet chip // arrives from — the chip sits on the same spoke, closer in. For a seat on // the top of the ring that's from below; on the bottom, from above. // // The blind badge has to be on the other side or the chip lands on top of // it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of // the badge's 288 square pixels, and it sits a layer higher, so the marker // telling the dealer who the big blind is was simply gone. node.classList.toggle("seatBelowCentre", pos.top > 50); node.querySelector(".seatChairNumber").textContent = index + 1; const dealerChip = node.querySelector(".dealerChip"); const hasButton = index === dealerIndex; dealerChip.classList.toggle("active", hasButton); dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer); // Every one of these announced itself as "D", so a screen reader heard a row // of nine identical buttons and couldn't tell which seat held the real one. // The label now says whose it is, or what pressing it would do. const who = (player.name || "").trim() || `seat ${index + 1}`; const chipLabel = hasButton ? `${who} has the dealer button` : `Give the dealer button to ${who}`; dealerChip.setAttribute("aria-label", chipLabel); dealerChip.title = chipLabel; // Tapping the puck is the same act as "Give the button" in the seat menu, // so it goes through the same hook — otherwise this one path would move // the button without moving the blinds that follow it. dealerChip.addEventListener("click", e => { e.stopPropagation(); this.hooks.onGiveButton(index); }); const sbBadge = node.querySelector(".sbBadge"); const bbBadge = node.querySelector(".bbBadge"); sbBadge.classList.toggle("visible", index === sbIndex); bbBadge.classList.toggle("visible", index === bbIndex); // A blind the dealer named by hand isn't where the button would have put // it, so it's marked — otherwise the table looks like it's just wrong. // The `!!` matters: getBlindSeats leaves `manual` undefined when the // blinds are positional, and classList.toggle with an undefined second // argument *flips* the class instead of clearing it, so the marker // appeared and disappeared on alternate renders. sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex); bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex); this.updatePuckContent(node, player, index); node.classList.toggle("seatSelected", index === this.selectedSeatIndex); this.wireSeatGestures(node, index); this.wireDragAndDrop(node, index); layer.appendChild(node); }); this.renderBets(); }, isRedSuit(card) { return /[HD]$/i.test(card || ""); }, SUIT_SYMBOLS: { S: "♠", H: "♥", D: "♦", C: "♣" }, // "KD" -> "K♦". Stored cards stay two plain characters so every existing reader // keeps working; the suit only becomes a symbol on its way to a human. formatCard(card) { const value = (card || "").toUpperCase(); if (!value) return ""; return `${value[0]}${this.SUIT_SYMBOLS[value[1]] || value[1] || ""}`; }, // An empty slot has to look like something you can tap, so it gets a placeholder // rather than going blank. paintCardSlot(slot, card) { const value = (card || "").toUpperCase(); slot.classList.toggle("cardRed", this.isRedSuit(value)); slot.classList.toggle("cardSlotEmpty", !value); slot.textContent = value ? this.formatCard(value) : "+"; }, // A chair nobody has claimed: no name typed, not dealt in, nothing in the pot. // Worth calling out, because at a short-handed table the open seats are what // the dealer is looking for. isEmptySeat(player) { const named = !!(player.name && player.name.trim()); const contributing = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount) > 0; return !named && !player.inHand && !contributing; }, updatePuckContent(node, player, index) { const empty = this.isEmptySeat(player); const nameEl = node.querySelector(".seatPuckName"); nameEl.textContent = empty ? "Empty" : (player.name || "").trim(); nameEl.classList.toggle("seatEmptyLabel", empty); const cardsEl = node.querySelector(".seatPuckCards"); cardsEl.innerHTML = ""; (player.cards || []).filter(Boolean).forEach((card, i) => { if (i > 0) cardsEl.appendChild(document.createTextNode(" ")); const span = document.createElement("span"); if (this.isRedSuit(card)) span.className = "cardRed"; span.textContent = this.formatCard(card); cardsEl.appendChild(span); }); // Always this player's total for the hand. What they've just pushed out on this // street is shown as a chip on the felt instead — the puck used to flip between // the two meanings depending on the moment, which is a bad thing for a number // sitting next to a stack size to do. const handTotal = Betting.handTotal(player); const total = handTotal > 0 ? handTotal : HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount); const totalEl = node.querySelector(".seatPuckTotal"); totalEl.textContent = total > 0 ? Money.format(total) : ""; this.fitAmount(totalEl); // A player with nothing behind used to leave this line blank, so the felt drew // an all-in exactly like a seat whose stack nobody had entered. On a table with // side pots that's the one distinction the dealer is working from — who can // still be bet at, and who is already committed for everything they have. const stackEl = node.querySelector(".seatPuckStack"); const stack = Betting.stackOf(this.state, player); const allIn = Betting.isAllIn(this.state, player); stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : ""; stackEl.classList.toggle("seatPuckAllIn", allIn); this.fitAmount(stackEl); node.classList.toggle("seatAllIn", allIn); node.classList.toggle("seatActing", index === this.state.actingSeatIndex); node.classList.toggle("seatEmpty", empty); node.classList.toggle("seatInactive", !player.inHand && !empty); node.classList.toggle("seatFolded", player.folded); node.classList.toggle("seatLive", player.inHand && !player.folded); }, // A seat puck is 64px of usable width. A tournament stack doesn't fit in that and, // because the number has no spaces to break on, it doesn't wrap or ellipsise // either — it just draws straight out over the neighbouring seat. Stepping the // font down keeps every digit, which matters: a dealer misreading a stack is a // worse outcome than a small one, so this shrinks rather than abbreviating. fitAmount(el) { const length = (el.textContent || "").length; el.classList.toggle("puckAmountLong", length >= 8 && length < 10); el.classList.toggle("puckAmountHuge", length >= 10); }, // Cheaper than a full renderSeats() — used after edits so the seat editor's inputs // (which live in a separate panel) never lose focus. refreshPuck(index) { const node = document.querySelector(`.seat[data-seat-index="${index}"]`); if (node) this.updatePuckContent(node, this.state.seats[index], index); }, // ---- Seat gestures ---- // // One puck, three intents: tap to edit, double-tap to fold, press and hold for // the quick menu. The single tap waits out the double-tap window before it // fires — a fifth of a second of latency on opening a form, in exchange for a // fold that doesn't also fling a sheet up over the table. LONG_PRESS_MS: 500, DOUBLE_TAP_MS: 220, DRAG_SLOP_PX: 8, pendingTap: null, wireSeatGestures(node, index) { let longPressTimer = null; let longPressFired = false; let startX = 0; let startY = 0; const cancelLongPress = () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }; node.addEventListener("pointerdown", e => { longPressFired = false; startX = e.clientX; startY = e.clientY; cancelLongPress(); longPressTimer = setTimeout(() => { longPressTimer = null; longPressFired = true; this.cancelPendingTap(); node.classList.remove("seatPressing"); this.hooks.onSeatLongPress(index); }, this.LONG_PRESS_MS); node.classList.add("seatPressing"); }); // Moving means they're dragging the seat, not holding it. node.addEventListener("pointermove", e => { if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) { cancelLongPress(); node.classList.remove("seatPressing"); } }); ["pointerup", "pointercancel", "pointerleave"].forEach(type => { node.addEventListener(type, () => { cancelLongPress(); node.classList.remove("seatPressing"); }); }); node.addEventListener("dragstart", () => { cancelLongPress(); this.cancelPendingTap(); node.classList.remove("seatPressing"); }); node.addEventListener("click", e => { // The long press already did something; don't also treat the release as a tap. if (longPressFired) { longPressFired = false; e.preventDefault(); e.stopPropagation(); return; } this.handleSeatTap(index); }); }, cancelPendingTap() { if (this.pendingTap) { clearTimeout(this.pendingTap.timer); this.pendingTap = null; } }, handleSeatTap(index) { if (this.pendingTap && this.pendingTap.index === index) { this.cancelPendingTap(); this.hooks.onSeatDoubleTap(index); return; } this.cancelPendingTap(); const timer = setTimeout(() => { this.pendingTap = null; this.toggleSeatEditor(index); }, this.DOUBLE_TAP_MS); this.pendingTap = { index, timer }; }, // Tapping the open seat again closes it — on a phone the editor is a sheet over // the table, and the seat you just tapped is the closest thing to hand for // dismissing it. toggleSeatEditor(index) { this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index; this.renderSeats(); this.renderSeatEditor(); }, wireDragAndDrop(node, index) { node.addEventListener("dragstart", e => { e.dataTransfer.setData("text/plain", String(index)); e.dataTransfer.effectAllowed = "move"; node.classList.add("dragging"); }); node.addEventListener("dragend", () => node.classList.remove("dragging")); node.addEventListener("dragover", e => { e.preventDefault(); node.classList.add("dropTarget"); }); node.addEventListener("dragleave", () => node.classList.remove("dropTarget")); node.addEventListener("drop", e => { e.preventDefault(); node.classList.remove("dropTarget"); const sourceIndex = Number(e.dataTransfer.getData("text/plain")); const targetIndex = index; if (Number.isNaN(sourceIndex) || sourceIndex === targetIndex) return; const seats = this.state.seats; [seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]]; this.hooks.save(this.state); this.renderSeats(); if (this.selectedSeatIndex === sourceIndex || this.selectedSeatIndex === targetIndex) { this.renderSeatEditor(); } }); }, // ---- Seat editor panel (the full form for whichever seat is selected) ---- closeSeatEditor() { this.selectedSeatIndex = null; this.renderSeats(); this.renderSeatEditor(); }, renderSeatEditor() { const title = document.getElementById("seatEditorTitle"); const body = document.getElementById("seatEditorBody"); // Drives the mobile bottom sheet: the panel only slides up over the table // while a seat is actually being edited. document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null); if (this.selectedSeatIndex === null) { title.textContent = "Select a seat"; body.innerHTML = '

Click a seat at the table to edit their name, cards, chips and anything else.

'; return; } const index = this.selectedSeatIndex; const player = this.state.seats[index]; title.textContent = this.puckLabel(player, index); body.innerHTML = ""; const template = document.getElementById("seatEditorTemplate"); const form = template.content.firstElementChild.cloneNode(true); this.wireSeatFields(form, player, index); this.renderChipCounts(form, player, index); this.renderCustomFields(form, player, index); this.renderSeatTotal(form, player); body.appendChild(form); }, renderSeatTotal(form, player) { const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount); form.querySelector(".seatTotalAmount").textContent = Money.format(total); }, commitSeatChange(form, player, index) { this.hooks.save(this.state); this.updatePotBadge(); this.renderSeatTotal(form, player); this.refreshPuck(index); }, wireSeatFields(form, player, index) { const nameInput = form.querySelector(".seatNameInput"); nameInput.value = player.name; nameInput.addEventListener("input", () => { player.name = nameInput.value; document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index); this.commitSeatChange(form, player, index); }); const inHandCheckbox = form.querySelector(".inHandCheckbox"); inHandCheckbox.checked = player.inHand; inHandCheckbox.addEventListener("change", () => { player.inHand = inHandCheckbox.checked; if (!player.inHand) player.folded = false; foldCheckboxRef.checked = player.folded; foldCheckboxRef.disabled = !player.inHand; this.commitSeatChange(form, player, index); }); const foldCheckboxRef = form.querySelector(".foldCheckbox"); foldCheckboxRef.checked = player.folded; foldCheckboxRef.disabled = !player.inHand; foldCheckboxRef.addEventListener("change", () => { player.folded = foldCheckboxRef.checked; this.commitSeatChange(form, player, index); }); const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")]; slots.forEach((slot, cardIdx) => { this.paintCardSlot(slot, player.cards[cardIdx]); slot.addEventListener("click", () => { CardPicker.open(this.state, { onPick: (seatIndex, pickedIdx, card) => { this.paintCardSlot(slots[pickedIdx], card); this.commitSeatChange(form, player, seatIndex); }, }, index, cardIdx); }); }); // What they've handed over tonight, shown next to what's in front of them so a // cash-out is one subtraction rather than a memory test. const boughtIn = Number(player.buyIns) || 0; const boughtInEl = form.querySelector(".seatBuyIns"); boughtInEl.textContent = boughtIn > 0 ? `Bought in ${Money.format(boughtIn)}` : ""; boughtInEl.hidden = boughtIn <= 0; const stackInput = form.querySelector(".stackInput"); stackInput.value = player.stack ?? ""; stackInput.addEventListener("input", () => { player.stack = stackInput.value === "" ? null : Number(stackInput.value); this.commitSeatChange(form, player, index); }); const amountInput = form.querySelector(".amountOverrideInput"); amountInput.value = player.amountOverride ?? ""; amountInput.addEventListener("input", () => { player.amountOverride = amountInput.value === "" ? null : amountInput.value; this.commitSeatChange(form, player, index); }); const addFieldBtn = form.querySelector(".addFieldBtn"); addFieldBtn.addEventListener("click", () => { player.customFields.push({ label: "", value: "" }); this.renderCustomFields(form, player, index); this.hooks.save(this.state); }); }, renderChipCounts(form, player, index) { const container = form.querySelector(".chipCounts"); container.innerHTML = ""; const template = document.getElementById("chipCountTemplate"); this.state.chipLegend.forEach(chip => { const row = template.content.firstElementChild.cloneNode(true); row.querySelector(".chipSwatch").style.background = chip.color; row.title = `$${chip.value} chips`; const input = row.querySelector(".chipCountInput"); input.value = player.chipCounts[chip.id] || 0; input.addEventListener("input", () => { const count = Math.max(0, Math.floor(Number(input.value)) || 0); player.chipCounts[chip.id] = count; this.commitSeatChange(form, player, index); }); container.appendChild(row); }); }, renderCustomFields(form, player, index) { const container = form.querySelector(".customFields"); container.innerHTML = ""; const template = document.getElementById("customFieldTemplate"); player.customFields.forEach((field, fieldIdx) => { const row = template.content.firstElementChild.cloneNode(true); const labelInput = row.querySelector(".customFieldLabel"); const valueInput = row.querySelector(".customFieldValue"); labelInput.value = field.label; valueInput.value = field.value; labelInput.addEventListener("input", () => { field.label = labelInput.value; this.hooks.save(this.state); }); valueInput.addEventListener("input", () => { field.value = valueInput.value; this.hooks.save(this.state); }); row.querySelector(".removeFieldBtn").addEventListener("click", () => { player.customFields.splice(fieldIdx, 1); this.renderCustomFields(form, player, index); this.hooks.save(this.state); }); container.appendChild(row); }); }, // ---- Chip legend ---- renderChipLegend() { const list = document.getElementById("chipLegendList"); list.innerHTML = ""; const template = document.getElementById("chipLegendRowTemplate"); this.state.chipLegend.forEach(chip => { const row = template.content.firstElementChild.cloneNode(true); const colorInput = row.querySelector(".chipColorInput"); const valueInput = row.querySelector(".chipValueInput"); colorInput.value = chip.color; valueInput.value = chip.value; colorInput.addEventListener("input", () => { chip.color = colorInput.value; this.hooks.save(this.state); }); valueInput.addEventListener("input", () => { chip.value = Number(valueInput.value) || 0; this.hooks.save(this.state); this.renderSeats(); this.renderSeatEditor(); this.updatePotBadge(); }); row.querySelector(".removeChipColorBtn").addEventListener("click", () => { this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id); this.hooks.save(this.state); this.renderAll(); }); list.appendChild(row); }); }, // ---- Pot breakdown ---- // Shared by the action bar (live, mid-hand) and the pot wizard (before winners are // picked), because both are showing the dealer the same announcement. buildPotBreakdown(pots, returns, nameFor, heading) { if (pots.length < 2 && returns.length === 0) return null; const wrap = document.createElement("div"); wrap.className = "potBreakdown"; if (heading) { const title = document.createElement("p"); title.className = "potBreakdownHeading"; title.textContent = heading; wrap.appendChild(title); } const addRow = (label, amount, who, className) => { const row = document.createElement("div"); row.className = `potLayer ${className || ""}`; const labelEl = document.createElement("span"); labelEl.className = "potLayerLabel"; labelEl.textContent = label; const amountEl = document.createElement("span"); amountEl.className = "potLayerAmount"; amountEl.textContent = Money.format(amount); const whoEl = document.createElement("span"); whoEl.className = "potLayerWho"; whoEl.textContent = who; row.appendChild(labelEl); row.appendChild(amountEl); row.appendChild(whoEl); wrap.appendChild(row); }; pots.forEach(pot => addRow(pot.label, pot.amount, pot.eligible.map(nameFor).join(", "))); returns.forEach(ret => addRow("Uncalled", ret.amount, `back to ${nameFor(ret.id)}`, "potLayerReturn")); return wrap; }, // ---- Session standings ---- renderStandings() { const list = document.getElementById("standingsList"); const emptyHint = document.getElementById("standingsEmptyHint"); const table = document.getElementById("standingsTable"); const standings = HoldemState.sessionStandings(this.state); list.innerHTML = ""; emptyHint.hidden = standings.length > 0; table.hidden = standings.length === 0; const template = document.getElementById("standingsRowTemplate"); standings.forEach(row => { const node = template.content.firstElementChild.cloneNode(true); node.querySelector(".standingsName").textContent = row.name; node.querySelector(".standingsIn").textContent = Money.format(row.in); node.querySelector(".standingsWon").textContent = Money.format(row.won); const netEl = node.querySelector(".standingsNet"); netEl.textContent = Money.formatSigned(row.net); netEl.classList.toggle("netUp", row.net > 0); netEl.classList.toggle("netDown", row.net < 0); // What they'd actually be handed if the night ended now: chips in front of // them less the cash they gave the banker. This was computed and written to // the CSV all along, but never shown to the person doing the paying out. const cashEl = node.querySelector(".standingsCashOut"); cashEl.textContent = Money.formatSigned(row.cashPosition); cashEl.classList.toggle("netUp", row.cashPosition > 0); cashEl.classList.toggle("netDown", row.cashPosition < 0); list.appendChild(node); }); this.renderBankReconciliation(); this.renderSettleUp(standings); this.renderStorageWarning(); }, // A toast is a moment; this table not being saved is a fact about the rest of the // night. It sits at the top of the panel a dealer opens to check the money, and it // stays there, because the raise pad dismisses toasts and the one time this warning // matters is the one time it must not have been missed. renderStorageWarning() { const host = document.getElementById("storageWarning"); if (!host) return; host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive; if (host.hidden) return; // Any of these can be true at once — a browser having trouble with storage // tends to have it in both directions — and the one that explains an empty // table goes first, because that's the question the dealer is standing there // asking. const lines = []; if (HoldemState.otherTabActive) { lines.push( "This table is open in another tab, and both are writing to it — " + "whichever acts last wins, so work done in the other one can vanish. " + "Close one, then reload this tab to pick up the table as it stands."); } if (HoldemState.loadFailed) { lines.push( "The saved table couldn't be read, so this one started empty. " + "The unreadable copy has been kept rather than written over."); } if (HoldemState.saveFailed) { lines.push( "This table isn't being saved — the browser refused. It'll keep working, " + "but closing or refreshing this tab loses the session. Export CSV below keeps a copy."); } host.textContent = lines.join(" "); }, // The last step the standings stopped short of. Six signed numbers at 2am is not // the answer to "who pays whom" — it's the arithmetic the dealer still has to do, // and it's the part of the night people actually get wrong. renderSettleUp(standings) { const host = document.getElementById("settleUp"); if (!host) return; const plan = SettleUp.plan(standings); const note = document.getElementById("settleUpNote"); const list = document.getElementById("settleUpList"); // Nothing to show before anyone is up or down. A panel that says "no payments" // to a table that hasn't played a hand is just noise in the sidebar. host.hidden = plan.payments.length === 0 && plan.balanced; if (host.hidden) return; list.innerHTML = ""; plan.payments.forEach(payment => { const row = document.createElement("li"); row.className = "settleUpRow"; const who = document.createElement("span"); who.className = "settleUpWho"; // An arrow rather than "pays": the direction is the whole content of the // line, and it survives being read at a glance from across the table. who.textContent = `${payment.from} → ${payment.to}`; row.appendChild(who); const amount = document.createElement("span"); amount.className = "settleUpAmount"; amount.textContent = Money.format(payment.amount); row.appendChild(amount); list.appendChild(row); }); if (plan.balanced) { const count = plan.payments.length; note.textContent = count === 1 ? "One payment and the night is square." : `${count} payments and the night is square.`; } else if (plan.shortBy > 0) { note.textContent = `${Money.format(plan.shortBy)} of winnings has nothing to pay it — there are more chips on the table than cash was taken for. Fix that above before anyone cashes out.`; } else { note.textContent = `${Money.format(plan.overBy)} is owed with nobody to pay it to — more cash was taken than there are chips in play. Fix that above before anyone cashes out.`; } note.classList.toggle("settleUpOff", !plan.balanced); }, // Does the cash box cover the chips on the table? Nothing asked this before, and // it's the question that decides whether the last two players can be paid. renderBankReconciliation() { const host = document.getElementById("bankReconcile"); if (!host) return; const bank = HoldemState.bankReconciliation(this.state); // Nothing to reconcile before anybody has handed over money or taken a chip. host.hidden = bank.cashIn === 0 && bank.chipsInPlay === 0; if (host.hidden) return; document.getElementById("bankCashIn").textContent = Money.format(bank.cashIn); document.getElementById("bankChipsInPlay").textContent = Money.format(bank.chipsInPlay); const verdict = document.getElementById("bankReconcileVerdict"); if (bank.balanced) { verdict.textContent = "Balanced — the cash covers every chip on the table."; } else if (bank.difference > 0) { verdict.textContent = `${Money.format(bank.difference)} more in chips than cash taken. Someone's rebuy probably wasn't recorded — the box will be short at cash-out.`; } else { verdict.textContent = `${Money.format(Math.abs(bank.difference))} more cash than chips in play. Someone has paid for chips they haven't been given.`; } verdict.classList.toggle("bankBalanced", bank.balanced); verdict.classList.toggle("bankOff", !bank.balanced); }, // ---- Hand counter + history ---- // toLocaleTimeString is the single most expensive call in the history panel, and a // logged hand's timestamp never changes, so the answer is worth keeping. handTimeCache: new Map(), formatHandTime(timestamp) { if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp); let formatted = ""; try { formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); } catch { formatted = ""; } this.handTimeCache.set(timestamp, formatted); return formatted; }, // Cheap stand-in for "has the log changed". Entries are immutable once written and // only ever added at the front, so the length, the counter and the newest row // between them catch a recorded hand, a cleared history and an undo. historySignature() { const history = this.state.handHistory || []; const newest = history[0]; return [ history.length, this.state.handNumber || 1, newest ? newest.handNumber : "", newest ? newest.timestamp : "", newest ? newest.pot : "", ].join("|"); }, // Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a // full re-render runs after every fold, call and raise. The log only changes when a // hand is settled or the history is cleared, perhaps once every thirty taps, so it // is skipped unless it actually did change. Everything else on the table reflects // the current hand and still redraws every time. renderHandHistory(force = false) { const signature = this.historySignature(); if (!force && signature === this.lastHistorySignature) return; this.lastHistorySignature = signature; document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`; const list = document.getElementById("handHistoryList"); const emptyHint = document.getElementById("handHistoryEmptyHint"); const history = this.state.handHistory || []; emptyHint.hidden = history.length > 0; document.getElementById("exportHistoryBtn").disabled = history.length === 0; // Built off-document so the panel is laid out once instead of once per row. const fragment = document.createDocumentFragment(); const template = document.getElementById("handHistoryRowTemplate"); history.forEach(entry => { const row = template.content.firstElementChild.cloneNode(true); row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`; row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot); row.querySelector(".handHistoryTime").textContent = this.formatHandTime(entry.timestamp); const payoutsEl = row.querySelector(".handHistoryPayouts"); payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", "); fragment.appendChild(row); }); list.innerHTML = ""; list.appendChild(fragment); }, }; if (typeof module !== "undefined" && module.exports) { module.exports = { Render }; }