The standings had a column headed "Cash out" and it did not hold an amount to pay.
It held the difference against the player's buy-in. Measured on a table where Ann
bought in for $200 and sits behind $340:
PLAYER NET CASH OUT
Ann $0 +$140
She is handed $340. The column called "Cash out" says $140, under a heading that
invites reading it as the payout, at the exact moment somebody is counting money onto
the table. The number a banker's hands actually follow — what the player is holding —
was computed all along and written to the CSV, and never shown on screen.
Three columns now, and they say what they are: Player, Chips, Up/down. Chips is a
plain amount and never signed, because it is a quantity of money to hand over. Up/down
keeps the sign and the colour, because it is a result. The two differ by exactly the
buy-in, which is asserted rather than assumed.
Chips counts what is out in the middle for the hand in progress as still theirs, for
the same reason the up/down figure does: the panel is read mid-hand constantly and a
stack that drops by the size of a bet looks like money going missing.
Net — won less paid in, from the hand log — comes off the screen to make room. It is
the weakest of the three: it only moves when a pot is resolved through the wizard, so
at a table where the dealer settles pots by hand it reads $0 all night beside two
columns that don't, and up/down already answers "how are they doing". It stays in the
CSV export, which loses nothing.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
1107 lines
50 KiB
JavaScript
1107 lines
50 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);
|
||
// A button on an empty chair normally means the table has drifted and wants
|
||
// fixing. Once it's the dead button it means the opposite — it's exactly where
|
||
// it belongs — so it gets marked rather than looking like the thing to correct.
|
||
const deadButtonHere = this.state.deadButton &&
|
||
HoldemState.deadPositionsApply(this.state) &&
|
||
!this.state.seats[dealerIndex].inHand;
|
||
|
||
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}`;
|
||
dealerChip.classList.toggle("dealerChipDead", hasButton && deadButtonHere);
|
||
const chipLabel = hasButton
|
||
? (deadButtonHere
|
||
? `Dead button on empty seat ${index + 1} — its player left out of the blinds`
|
||
: `${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);
|
||
// The editor deliberately refreshes narrowly rather than redrawing everything —
|
||
// a full render on every keystroke would take the name field out from under the
|
||
// dealer mid-word. But the action bar answers "is there a table here at all",
|
||
// and seating somebody is exactly what changes that answer. Without this it
|
||
// stayed hidden until something else happened to redraw: two players seated, a
|
||
// dealable hand, and a blank bar until the page was reloaded.
|
||
//
|
||
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
|
||
// anyway, and nothing inside it can hold focus while the sidebar has it.
|
||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||
},
|
||
|
||
wireSeatFields(form, player, index) {
|
||
const nameInput = form.querySelector(".seatNameInput");
|
||
nameInput.value = player.name;
|
||
nameInput.addEventListener("input", () => {
|
||
// Putting a name on an empty chair is somebody sitting down, so they get
|
||
// dealt in. It used to be two separate acts, and the second one was easy to
|
||
// miss: a dealer setting up typed six names, got six seats that still
|
||
// weren't in the hand, and the action bar hid itself because by its reckoning
|
||
// nobody was playing. Six people at the table and a blank screen.
|
||
//
|
||
// Only for a chair that was empty — no name and not dealt in. Renaming
|
||
// somebody who is sitting out is a correction, not an arrival, and dealing
|
||
// them back in over a typo would be the tool overruling the dealer.
|
||
const wasEmpty = !player.name.trim() && !player.inHand;
|
||
player.name = nameInput.value;
|
||
if (wasEmpty && player.name.trim()) {
|
||
player.inHand = true;
|
||
player.folded = false;
|
||
const inHandBox = form.querySelector(".inHandCheckbox");
|
||
if (inHandBox) inHandBox.checked = true;
|
||
const foldBox = form.querySelector(".foldCheckbox");
|
||
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
|
||
}
|
||
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);
|
||
});
|
||
|
||
this.renderColourUp();
|
||
},
|
||
|
||
// Each stack as the chips that make it.
|
||
//
|
||
// The legend has only ever read chips *into* an amount — counting what's in front
|
||
// of somebody. Colouring up at the end of a level is the same question backwards,
|
||
// and it's the one the dealer has to answer with a rack in one hand: $205 is four
|
||
// fifties and a five. So is counting a stack out for a player who's leaving.
|
||
//
|
||
// Only when there's a legend to work from and a stack to break down; a table that
|
||
// never set its chip colours up gets nothing, which is what it had before.
|
||
renderColourUp() {
|
||
const host = document.getElementById("colourUp");
|
||
const list = document.getElementById("colourUpList");
|
||
if (!host || !list) return;
|
||
|
||
const legend = this.state.chipLegend || [];
|
||
const rows = this.state.seats
|
||
.map((player, index) => ({ player, index }))
|
||
.filter(({ player }) => Betting.round(Number(player.stack) || 0) > 0)
|
||
.map(({ player, index }) => ({
|
||
name: HoldemState.displayName(player, index),
|
||
stack: Betting.round(Number(player.stack) || 0),
|
||
chips: Money.formatChipBreakdown(Number(player.stack) || 0, legend),
|
||
}))
|
||
.filter(row => row.chips);
|
||
|
||
host.hidden = rows.length === 0;
|
||
if (host.hidden) return;
|
||
|
||
list.innerHTML = "";
|
||
rows.forEach(row => {
|
||
const term = document.createElement("dt");
|
||
term.className = "colourUpWho";
|
||
term.textContent = `${row.name} — ${Money.format(row.stack)}`;
|
||
const detail = document.createElement("dd");
|
||
detail.className = "colourUpChips";
|
||
detail.textContent = row.chips;
|
||
list.appendChild(term);
|
||
list.appendChild(detail);
|
||
});
|
||
},
|
||
|
||
// ---- 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);
|
||
|
||
// The cash to count out, which is a plain amount and never signed — this
|
||
// is the one the banker's hands follow. It sat only in the CSV: on screen
|
||
// the column headed "Cash out" held the difference against their buy-in
|
||
// instead, so a player up $140 on a $340 stack read as "$140" at the exact
|
||
// moment somebody was counting money onto the table.
|
||
node.querySelector(".standingsChips").textContent = Money.format(row.chipsInFront);
|
||
|
||
// And whether that leaves them up or down, which is the question the rest
|
||
// of the table asks and the one the old column was actually answering.
|
||
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 {
|
||
// If a pot was thrown away on purpose, that's where the gap went, and the
|
||
// dealer already answered a dialog about it. Telling them to go and fix it
|
||
// would be sending them after their own decision.
|
||
const discarded = Math.max(0, Number(this.state.discarded) || 0);
|
||
note.textContent = discarded > 0
|
||
? `${Money.format(plan.overBy)} is owed with nobody to pay it to. ${Money.format(discarded)} was taken off the table when a hand was abandoned, which accounts for ${discarded >= plan.overBy ? "it" : "some of it"}.`
|
||
: `${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);
|
||
|
||
// An abandoned pot the dealer confirmed throwing away is money that left the
|
||
// table on purpose. It gets its own line so the two columns still add up in
|
||
// front of them, rather than looking like a shortfall they have to explain.
|
||
const discardedRow = document.getElementById("bankDiscardedRow");
|
||
if (discardedRow) {
|
||
discardedRow.hidden = !(bank.discarded > 0);
|
||
if (bank.discarded > 0) {
|
||
document.getElementById("bankDiscarded").textContent = Money.format(bank.discarded);
|
||
}
|
||
}
|
||
|
||
const verdict = document.getElementById("bankReconcileVerdict");
|
||
if (bank.balanced) {
|
||
verdict.textContent = bank.discarded > 0
|
||
? `Balanced — the cash covers every chip on the table, once the ${Money.format(bank.discarded)} taken off it is counted.`
|
||
: "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 : "",
|
||
// Typing in the filter changes the list without changing the history, and
|
||
// the redraw is skipped when the signature matches — leave this out and the
|
||
// box takes input and does nothing.
|
||
this.historyFilter || "",
|
||
].join("|");
|
||
},
|
||
|
||
// What the dealer opens the log for is one hand: the one somebody is arguing
|
||
// about. A night runs to sixty or more, five at a time in the panel, so finding it
|
||
// meant scrolling and reading every row. A name, an amount, or a hand number
|
||
// narrows it instead.
|
||
//
|
||
// Matching is deliberately loose. "fay" finds Fay whatever case it's typed in;
|
||
// "190" finds a $190 pot, a $190 payout, or hand 190, because a dealer half
|
||
// remembering an amount shouldn't have to know which of those they mean; "#12"
|
||
// pins it to the hand number, for when they do.
|
||
handMatchesFilter(entry, query) {
|
||
if (!query) return true;
|
||
const term = query.trim().toLowerCase();
|
||
if (!term) return true;
|
||
|
||
const handNumber = String(entry.handNumber || "");
|
||
if (term.startsWith("#")) return handNumber.startsWith(term.slice(1));
|
||
|
||
// Only what the row itself shows. Matching the hand's full ledger as well was
|
||
// the first attempt, on the reasoning that a dealer might hunt for the hand
|
||
// that broke somebody by the loser's name — but the ledger holds everyone who
|
||
// put a chip in, so at a table where nobody folds it matched every hand.
|
||
// Measured on a seven-handed night: "Fay" returned 60 of 60. Searching text the
|
||
// panel doesn't display is a filter that looks broken even when it's right.
|
||
const labels = (entry.payouts || []).map(p => String(p.label || "").toLowerCase()).join(" ");
|
||
if (labels.includes(term)) return true;
|
||
|
||
// Amounts, matched on the digits so "$190", "190" and "1,90" all behave.
|
||
const digits = term.replace(/[^0-9.]/g, "");
|
||
if (digits) {
|
||
const amounts = [entry.pot, ...(entry.payouts || []).map(p => p.amount)]
|
||
.map(a => String(Money.round(Number(a) || 0)));
|
||
if (amounts.some(a => a.startsWith(digits))) return true;
|
||
if (handNumber === digits) return true;
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// 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;
|
||
|
||
const query = this.historyFilter || "";
|
||
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
|
||
|
||
// How much of the night is being hidden. Without it a filter that matches one
|
||
// hand looks exactly like a table that only played one, which is the sort of
|
||
// thing a dealer reads wrong at 2am with somebody leaning over the rail.
|
||
const count = document.getElementById("handHistoryCount");
|
||
if (count) {
|
||
count.hidden = !query || history.length === 0;
|
||
count.textContent = `${shown.length} of ${history.length}`;
|
||
}
|
||
const noMatch = document.getElementById("handHistoryNoMatch");
|
||
if (noMatch) {
|
||
noMatch.hidden = !(query && history.length > 0 && shown.length === 0);
|
||
noMatch.textContent = `No hand matches “${query.trim()}”. The log still has all ${history.length}.`;
|
||
}
|
||
const filterBox = document.getElementById("handHistoryFilter");
|
||
if (filterBox) filterBox.hidden = 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");
|
||
shown.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 };
|
||
}
|