Give the Hold'em dealer a session record: hand history, CSV export and standings #14

Merged
jheaps merged 3 commits from feature/casino-holdem-hand-history into master 2026-08-31 18:31:19 -06:00
7 changed files with 420 additions and 4 deletions
@@ -10,6 +10,7 @@
<div id="holdemHeaderLeft"> <div id="holdemHeaderLeft">
<h1>Texas Hold'em Dealer</h1> <h1>Texas Hold'em Dealer</h1>
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p> <p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</div> </div>
<div id="holdemHeaderActions"> <div id="holdemHeaderActions">
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button> <button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
@@ -84,6 +85,34 @@
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea> <textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
</div> </div>
</details> </details>
<details class="sidebarPanel">
<summary>Session Standings</summary>
<div class="panelBody">
<div id="standingsTable">
<div class="standingsRow standingsHeader">
<span>Player</span>
<span>In</span>
<span>Won</span>
<span>Net</span>
</div>
<div id="standingsList"></div>
</div>
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
</div>
</details>
<details class="sidebarPanel">
<summary>Hand History</summary>
<div class="panelBody">
<div id="handHistoryList"></div>
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots show up here as you play, in case a chip count is disputed later.</p>
<div class="handHistoryActions">
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
</div>
</div>
</details>
</aside> </aside>
</div> </div>
</div> </div>
@@ -156,6 +185,26 @@
</div> </div>
</template> </template>
<template id="standingsRowTemplate">
<div class="standingsRow">
<span class="standingsName"></span>
<span class="standingsIn"></span>
<span class="standingsWon"></span>
<span class="standingsNet"></span>
</div>
</template>
<template id="handHistoryRowTemplate">
<div class="handHistoryRow">
<div class="handHistoryRowHeader">
<span class="handHistoryHandNumber"></span>
<span class="handHistoryPot"></span>
<span class="handHistoryTime"></span>
</div>
<div class="handHistoryPayouts"></div>
</div>
</template>
<div id="potWizardOverlay" hidden> <div id="potWizardOverlay" hidden>
<div id="potWizardModal"> <div id="potWizardModal">
<div class="potWizardHeader"> <div class="potWizardHeader">
@@ -175,5 +224,6 @@
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script> <script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script> <script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script> <script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script> <script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
} }
+125
View File
@@ -51,6 +51,18 @@ body {
font-size: 0.9rem; font-size: 0.9rem;
} }
.handCounterBadge {
display: inline-block;
margin-top: 0.5rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: rgba(216, 180, 106, 0.12);
border: 1px solid rgba(216, 180, 106, 0.4);
color: #d8b46a;
font-size: 0.75rem;
letter-spacing: 0.04em;
}
#holdemHeaderActions { #holdemHeaderActions {
display: flex; display: flex;
gap: 0.6rem; gap: 0.6rem;
@@ -108,6 +120,18 @@ body {
font-size: 0.75rem; font-size: 0.75rem;
} }
.holdemBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.handHistoryActions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* ---------- Layout ---------- */ /* ---------- Layout ---------- */
#holdemLayout { #holdemLayout {
@@ -593,6 +617,107 @@ body {
padding: 0.3rem 0.5rem; padding: 0.3rem 0.5rem;
} }
#standingsTable {
max-height: 260px;
overflow-y: auto;
}
.standingsRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 0.5rem;
align-items: baseline;
padding: 0.3rem 0.1rem;
font-size: 0.78rem;
border-bottom: 1px solid #232323;
}
.standingsRow > span:not(:first-child) {
text-align: right;
font-variant-numeric: tabular-nums;
min-width: 4.5rem;
}
.standingsHeader {
color: #777;
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom-color: #3a3a3a;
position: sticky;
top: 0;
background: #1b1b1b;
}
.standingsName {
color: #eee;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.standingsIn,
.standingsWon {
color: #999;
}
.standingsNet {
color: #bbb;
font-weight: 600;
}
.standingsNet.netUp {
color: #4ec97a;
}
.standingsNet.netDown {
color: #e07b7b;
}
#handHistoryList {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 260px;
overflow-y: auto;
}
.handHistoryRow {
background: #141414;
border: 1px solid #2c2c2c;
border-radius: 6px;
padding: 0.45rem 0.6rem;
}
.handHistoryRowHeader {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8rem;
}
.handHistoryHandNumber {
color: #d8b46a;
font-weight: 600;
}
.handHistoryPot {
color: #eee;
}
.handHistoryTime {
margin-left: auto;
color: #777;
font-size: 0.72rem;
}
.handHistoryPayouts {
margin-top: 0.15rem;
color: #aaa;
font-size: 0.75rem;
line-height: 1.4;
}
#tableNotes { #tableNotes {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -0,0 +1,70 @@
// Turns the in-memory hand history into a CSV file the dealer can download for a
// paper trail — a house manager or a disputed chip count needs something that
// outlives the browser tab, not just a scrollable panel in the sidebar.
const HandHistoryExport = {
csvField(value) {
const str = String(value ?? "");
return /[",\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
},
formatTimestamp(timestamp) {
try {
return new Date(timestamp).toLocaleString();
} catch {
return "";
}
},
toCsv(handHistory, standings = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
const payouts = (entry.payouts || [])
.map(p => `${p.label}: ${Money.format(p.amount)}`)
.join("; ");
rows.push([
entry.handNumber,
this.formatTimestamp(entry.timestamp),
Money.format(entry.pot),
payouts,
]);
});
// The settle-up numbers go in the same file as the hands they came from, so a
// dealer handing over one export isn't asked for a second one at cash-out.
if (standings.length > 0) {
rows.push([]);
rows.push(["Session Standings"]);
rows.push(["Player", "Paid In", "Won", "Net"]);
standings.forEach(row => {
rows.push([row.name, Money.format(row.in), Money.format(row.won), Money.formatSigned(row.net)]);
});
}
return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n");
},
fileName() {
const now = new Date();
const pad = n => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
return `holdem-hand-history-${stamp}.csv`;
},
download(handHistory, standings = []) {
const csv = this.toCsv(handHistory, standings);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = this.fileName();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = HandHistoryExport;
}
+14 -1
View File
@@ -63,7 +63,8 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
document.getElementById("resolvePotBtn").addEventListener("click", () => { document.getElementById("resolvePotBtn").addEventListener("click", () => {
PotWizard.open(state, () => { PotWizard.open(state, payouts => {
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
HoldemState.startNextHand(state); HoldemState.startNextHand(state);
hooks.save(state); hooks.save(state);
Render.renderAll(); Render.renderAll();
@@ -74,4 +75,16 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("potWizardOverlay").addEventListener("click", e => { document.getElementById("potWizardOverlay").addEventListener("click", e => {
if (e.target.id === "potWizardOverlay") PotWizard.close(); if (e.target.id === "potWizardOverlay") PotWizard.close();
}); });
document.getElementById("clearHistoryBtn").addEventListener("click", () => {
if (!confirm("Clear the hand history and reset the hand counter to 1? This can't be undone.")) return;
HoldemState.clearHistory(state);
hooks.save(state);
Render.renderAll();
});
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
});
}); });
@@ -37,7 +37,7 @@ const PotWizard = {
}, },
labelFor(player, seatIndex) { labelFor(player, seatIndex) {
const name = player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`; const name = HoldemState.displayName(player, seatIndex);
const cards = (player.cards || []).filter(Boolean).join(" "); const cards = (player.cards || []).filter(Boolean).join(" ");
return cards ? `${name} (${cards})` : name; return cards ? `${name} (${cards})` : name;
}, },
@@ -175,8 +175,13 @@ const PotWizard = {
applyBtn.className = "holdemBtn holdemBtnAccent"; applyBtn.className = "holdemBtn holdemBtnAccent";
applyBtn.textContent = "Start Next Hand"; applyBtn.textContent = "Start Next Hand";
applyBtn.addEventListener("click", () => { applyBtn.addEventListener("click", () => {
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
id,
label: this.playerLabelById.get(id),
amount,
}));
this.close(); this.close();
this.onFinished(); this.onFinished(payouts);
}); });
actions.appendChild(applyBtn); actions.appendChild(applyBtn);
@@ -30,6 +30,8 @@ const Render = {
this.updatePotBadge(); this.updatePotBadge();
this.renderSetupFields(); this.renderSetupFields();
this.renderTableHint(); this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
}, },
renderSetupFields() { renderSetupFields() {
@@ -69,7 +71,7 @@ const Render = {
}, },
puckLabel(player, seatIndex) { puckLabel(player, seatIndex) {
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`; return HoldemState.displayName(player, seatIndex);
}, },
renderSeats() { renderSeats() {
@@ -349,6 +351,68 @@ const Render = {
list.appendChild(row); list.appendChild(row);
}); });
}, },
// ---- 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);
list.appendChild(node);
});
},
// ---- Hand counter + history ----
formatHandTime(timestamp) {
try {
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
},
renderHandHistory() {
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
const list = document.getElementById("handHistoryList");
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
list.innerHTML = "";
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
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(", ");
list.appendChild(row);
});
},
}; };
const Money = { const Money = {
@@ -356,6 +420,15 @@ const Money = {
const value = Number(amount) || 0; const value = Number(amount) || 0;
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}, },
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
// glance, and the sign belongs out front where the eye lands first.
formatSigned(amount) {
const value = Number(amount) || 0;
if (value > 0) return `+${this.format(value)}`;
if (value < 0) return `-${this.format(Math.abs(value))}`;
return this.format(0);
},
}; };
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
@@ -29,6 +29,8 @@ const HoldemState = {
}; };
}, },
MAX_HISTORY_ENTRIES: 200,
createDefault() { createDefault() {
const seatCount = this.DEFAULT_SEAT_COUNT; const seatCount = this.DEFAULT_SEAT_COUNT;
return { return {
@@ -40,6 +42,8 @@ const HoldemState = {
chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })), chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })),
seats: Array.from({ length: seatCount }, () => this.makePlayer()), seats: Array.from({ length: seatCount }, () => this.makePlayer()),
notes: "", notes: "",
handNumber: 1,
handHistory: [],
}; };
}, },
@@ -49,6 +53,8 @@ const HoldemState = {
if (!raw) return this.createDefault(); if (!raw) return this.createDefault();
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault(); if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
return parsed; return parsed;
} catch { } catch {
return this.createDefault(); return this.createDefault();
@@ -82,6 +88,12 @@ const HoldemState = {
return state; return state;
}, },
// What to call a seat in logs, payouts and standings when the dealer hasn't
// typed a name in yet.
displayName(player, seatIndex) {
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
},
// Contribution the table calculator will treat this player as having put in. // Contribution the table calculator will treat this player as having put in.
computeContribution(player, chipLegend, basePotAmount) { computeContribution(player, chipLegend, basePotAmount) {
if (!player.inHand) return 0; if (!player.inHand) return 0;
@@ -137,6 +149,74 @@ const HoldemState = {
p.amountOverride = null; p.amountOverride = null;
}); });
}, },
// Appends a resolved-pot record to the running session log a dealer can point to
// for chip-count disputes, then advances the hand counter. `payouts` is
// [{ id, label, amount }, ...] — who got paid what out of this hand's pot(s).
//
// Must be called before startNextHand() clears the seats: the per-player `ledger`
// it stores alongside the payouts is what session standings are summed from later,
// and it needs this hand's contributions while they're still on the table.
recordHand(state, payouts) {
if (typeof state.handNumber !== "number") state.handNumber = 1;
if (!Array.isArray(state.handHistory)) state.handHistory = [];
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
const wonById = new Map(payouts.map(p => [p.id, p.amount]));
const ledger = state.seats
.map((player, index) => ({
id: player.id,
name: this.displayName(player, index),
in: this.computeContribution(player, state.chipLegend, state.basePotAmount),
won: wonById.get(player.id) || 0,
}))
.filter(row => row.in > 0 || row.won > 0);
state.handHistory.unshift({
handNumber: state.handNumber,
timestamp: Date.now(),
pot,
payouts,
ledger,
});
if (state.handHistory.length > this.MAX_HISTORY_ENTRIES) {
state.handHistory.length = this.MAX_HISTORY_ENTRIES;
}
state.handNumber += 1;
},
clearHistory(state) {
state.handHistory = [];
state.handNumber = 1;
},
// Running per-player totals for the whole session, so a dealer settling up at the
// end of the night reads one number per person instead of adding up every hand.
// Keyed by seat id, which survives renames and reseating; hands recorded before
// the ledger existed simply don't contribute to the totals.
sessionStandings(state) {
const totals = new Map();
(state.handHistory || []).forEach(entry => {
(entry.ledger || []).forEach(row => {
// History is newest-first, so the first name seen for an id is the
// most recent one the dealer typed.
const running = totals.get(row.id) || { id: row.id, name: row.name, in: 0, won: 0 };
running.in += row.in;
running.won += row.won;
totals.set(row.id, running);
});
});
return Array.from(totals.values())
.map(t => ({
...t,
in: Math.round(t.in * 100) / 100,
won: Math.round(t.won * 100) / 100,
net: Math.round((t.won - t.in) * 100) / 100,
}))
.sort((a, b) => b.net - a.net);
},
}; };
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {