Track session standings so the dealer can settle up at the end of the night

The hand log answered "what happened in hand 12"; it couldn't answer "what
does Alice owe". Each resolved pot now records a per-seat ledger (what that
player put in, what they took down) alongside the payouts, and a Session
Standings panel sums it into one row per player — paid in, won, net, sorted
by who's up. The CSV export carries the same table, so one file covers both
the hand-by-hand trail and the cash-out numbers.

Keyed by seat id rather than name, so renaming or reseating a player doesn't
split their totals. Hands recorded before this change have no ledger and are
skipped rather than counted wrong.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-08-31 17:37:48 -06:00
co-authored by Claude Sonnet 5
parent d168fedf1f
commit bcfc1f2f52
7 changed files with 189 additions and 7 deletions
@@ -86,6 +86,22 @@
</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">
@@ -169,6 +185,15 @@
</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">
@@ -617,6 +617,63 @@ 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;
@@ -15,7 +15,7 @@ const HandHistoryExport = {
}
},
toCsv(handHistory) {
toCsv(handHistory, standings = []) {
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
// handHistory is stored newest-first; the export reads better oldest-first.
[...handHistory].reverse().forEach(entry => {
@@ -29,6 +29,18 @@ const HandHistoryExport = {
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");
},
@@ -39,8 +51,8 @@ const HandHistoryExport = {
return `holdem-hand-history-${stamp}.csv`;
},
download(handHistory) {
const csv = this.toCsv(handHistory);
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");
@@ -85,6 +85,6 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
if ((state.handHistory || []).length === 0) return;
HandHistoryExport.download(state.handHistory);
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;
},
@@ -176,6 +176,7 @@ const PotWizard = {
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,
}));
@@ -30,6 +30,7 @@ const Render = {
this.updatePotBadge();
this.renderSetupFields();
this.renderTableHint();
this.renderStandings();
this.renderHandHistory();
},
@@ -70,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() {
@@ -351,6 +352,34 @@ const Render = {
});
},
// ---- 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) {
@@ -391,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) {
@@ -88,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;
@@ -146,17 +152,32 @@ const HoldemState = {
// 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
// [{ label, amount }, ...] — who got paid what out of this hand's pot(s).
// [{ 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;
@@ -168,6 +189,34 @@ const HoldemState = {
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) {