Opening the tool in a second tab -- tapping the bookmark again rather than hunting for
the tab you already had -- silently throws away whatever the first one is doing.
Each tab holds its own copy in memory and each save writes the whole table, so the
second one to act wins. Driven with two tabs on one profile:
tab A: deal, and Ann calls stored -> bettingOpen true, committed [0,1,2,2]
tab B: type a name in seat 5 stored -> bettingOpen false, committed [0,0,0,0]
The deal, the blinds and the call are gone, and neither tab said anything. That is the
same silent shape as the save and load failures either side of this change.
The browser already tells us: a storage event fires in every other tab when the key
changes, and never in the one that wrote. So a tab that sees its table move underneath
it now says so, once, with the way out attached:
toast: "This table is open in another tab. Whichever acts last wins." [Reload]
panel: "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."
Watched rather than merged, deliberately. Replacing the table under a dealer's hands
in the middle of a hand would be its own hazard, and reloading is the only resolution
that does not throw somebody's work away -- so it is offered rather than taken.
6 tests (269 total): a write from elsewhere is noticed, another app's key and our own
recovery key are not our business, ten writes produce one warning, watching twice
listens once, no listener is not an error, and no window at all is not an error.
Driven with two live tabs: both quiet at first, A deals and B warns with the reload
offer, A stays quiet about its own writes, two more actions in A add no further toasts
in B, and reloading B picks up A's table -- bettingOpen true, committed [2,1,2,2] --
with the warning cleared. Audit and contrast clean, no console errors.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
943 lines
41 KiB
JavaScript
943 lines
41 KiB
JavaScript
// 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 = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
|
||
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 };
|
||
}
|