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:
@@ -86,6 +86,22 @@
|
|||||||
</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">
|
<details class="sidebarPanel">
|
||||||
<summary>Hand History</summary>
|
<summary>Hand History</summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
@@ -169,6 +185,15 @@
|
|||||||
</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">
|
<template id="handHistoryRowTemplate">
|
||||||
<div class="handHistoryRow">
|
<div class="handHistoryRow">
|
||||||
<div class="handHistoryRowHeader">
|
<div class="handHistoryRowHeader">
|
||||||
|
|||||||
@@ -617,6 +617,63 @@ 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 {
|
#handHistoryList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const HandHistoryExport = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
toCsv(handHistory) {
|
toCsv(handHistory, standings = []) {
|
||||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||||
[...handHistory].reverse().forEach(entry => {
|
[...handHistory].reverse().forEach(entry => {
|
||||||
@@ -29,6 +29,18 @@ const HandHistoryExport = {
|
|||||||
payouts,
|
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");
|
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`;
|
return `holdem-hand-history-${stamp}.csv`;
|
||||||
},
|
},
|
||||||
|
|
||||||
download(handHistory) {
|
download(handHistory, standings = []) {
|
||||||
const csv = this.toCsv(handHistory);
|
const csv = this.toCsv(handHistory, standings);
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|||||||
@@ -85,6 +85,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||||
if ((state.handHistory || []).length === 0) return;
|
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) {
|
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;
|
||||||
},
|
},
|
||||||
@@ -176,6 +176,7 @@ const PotWizard = {
|
|||||||
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]) => ({
|
const payouts = Array.from(this.totals.entries()).map(([id, amount]) => ({
|
||||||
|
id,
|
||||||
label: this.playerLabelById.get(id),
|
label: this.playerLabelById.get(id),
|
||||||
amount,
|
amount,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const Render = {
|
|||||||
this.updatePotBadge();
|
this.updatePotBadge();
|
||||||
this.renderSetupFields();
|
this.renderSetupFields();
|
||||||
this.renderTableHint();
|
this.renderTableHint();
|
||||||
|
this.renderStandings();
|
||||||
this.renderHandHistory();
|
this.renderHandHistory();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -70,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() {
|
||||||
@@ -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 ----
|
// ---- Hand counter + history ----
|
||||||
|
|
||||||
formatHandTime(timestamp) {
|
formatHandTime(timestamp) {
|
||||||
@@ -391,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) {
|
||||||
|
|||||||
@@ -88,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;
|
||||||
@@ -146,17 +152,32 @@ const HoldemState = {
|
|||||||
|
|
||||||
// Appends a resolved-pot record to the running session log a dealer can point to
|
// 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
|
// 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) {
|
recordHand(state, payouts) {
|
||||||
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
if (typeof state.handNumber !== "number") state.handNumber = 1;
|
||||||
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
if (!Array.isArray(state.handHistory)) state.handHistory = [];
|
||||||
|
|
||||||
const pot = payouts.reduce((sum, p) => sum + p.amount, 0);
|
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({
|
state.handHistory.unshift({
|
||||||
handNumber: state.handNumber,
|
handNumber: state.handNumber,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
pot,
|
pot,
|
||||||
payouts,
|
payouts,
|
||||||
|
ledger,
|
||||||
});
|
});
|
||||||
if (state.handHistory.length > this.MAX_HISTORY_ENTRIES) {
|
if (state.handHistory.length > this.MAX_HISTORY_ENTRIES) {
|
||||||
state.handHistory.length = this.MAX_HISTORY_ENTRIES;
|
state.handHistory.length = this.MAX_HISTORY_ENTRIES;
|
||||||
@@ -168,6 +189,34 @@ const HoldemState = {
|
|||||||
state.handHistory = [];
|
state.handHistory = [];
|
||||||
state.handNumber = 1;
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user