Merge pull request 'Give the Hold'em dealer a session record: hand history, CSV export and standings' (#14) from feature/casino-holdem-hand-history into master
Build and Deploy / build-deploy (push) Successful in 1m13s
Build and Deploy / build-deploy (push) Successful in 1m13s
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
<div id="holdemHeaderLeft">
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<p class="holdemSubtitle">Seat your players, track the action, split the pot.</p>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -84,6 +85,34 @@
|
||||
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,6 +185,26 @@
|
||||
</div>
|
||||
</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="potWizardModal">
|
||||
<div class="potWizardHeader">
|
||||
@@ -175,5 +224,6 @@
|
||||
<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/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>
|
||||
}
|
||||
|
||||
@@ -51,6 +51,18 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
@@ -108,6 +120,18 @@ body {
|
||||
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 ---------- */
|
||||
|
||||
#holdemLayout {
|
||||
@@ -593,6 +617,107 @@ body {
|
||||
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 {
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
@@ -63,7 +63,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
document.getElementById("resolvePotBtn").addEventListener("click", () => {
|
||||
PotWizard.open(state, () => {
|
||||
PotWizard.open(state, payouts => {
|
||||
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
|
||||
HoldemState.startNextHand(state);
|
||||
hooks.save(state);
|
||||
Render.renderAll();
|
||||
@@ -74,4 +75,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
||||
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) {
|
||||
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(" ");
|
||||
return cards ? `${name} (${cards})` : name;
|
||||
},
|
||||
@@ -175,8 +175,13 @@ const PotWizard = {
|
||||
applyBtn.className = "holdemBtn holdemBtnAccent";
|
||||
applyBtn.textContent = "Start Next Hand";
|
||||
applyBtn.addEventListener("click", () => {
|
||||
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||
id,
|
||||
label: this.playerLabelById.get(id),
|
||||
amount,
|
||||
}));
|
||||
this.close();
|
||||
this.onFinished();
|
||||
this.onFinished(payouts);
|
||||
});
|
||||
actions.appendChild(applyBtn);
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ const Render = {
|
||||
this.updatePotBadge();
|
||||
this.renderSetupFields();
|
||||
this.renderTableHint();
|
||||
this.renderStandings();
|
||||
this.renderHandHistory();
|
||||
},
|
||||
|
||||
renderSetupFields() {
|
||||
@@ -69,7 +71,7 @@ const Render = {
|
||||
},
|
||||
|
||||
puckLabel(player, seatIndex) {
|
||||
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
|
||||
return HoldemState.displayName(player, seatIndex);
|
||||
},
|
||||
|
||||
renderSeats() {
|
||||
@@ -349,6 +351,68 @@ const Render = {
|
||||
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 = {
|
||||
@@ -356,6 +420,15 @@ const Money = {
|
||||
const value = Number(amount) || 0;
|
||||
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) {
|
||||
|
||||
@@ -29,6 +29,8 @@ const HoldemState = {
|
||||
};
|
||||
},
|
||||
|
||||
MAX_HISTORY_ENTRIES: 200,
|
||||
|
||||
createDefault() {
|
||||
const seatCount = this.DEFAULT_SEAT_COUNT;
|
||||
return {
|
||||
@@ -40,6 +42,8 @@ const HoldemState = {
|
||||
chipLegend: this.DEFAULT_CHIP_LEGEND.map(c => ({ ...c })),
|
||||
seats: Array.from({ length: seatCount }, () => this.makePlayer()),
|
||||
notes: "",
|
||||
handNumber: 1,
|
||||
handHistory: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -49,6 +53,8 @@ const HoldemState = {
|
||||
if (!raw) return this.createDefault();
|
||||
const parsed = JSON.parse(raw);
|
||||
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;
|
||||
} catch {
|
||||
return this.createDefault();
|
||||
@@ -82,6 +88,12 @@ const HoldemState = {
|
||||
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.
|
||||
computeContribution(player, chipLegend, basePotAmount) {
|
||||
if (!player.inHand) return 0;
|
||||
@@ -137,6 +149,74 @@ const HoldemState = {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user