From db208a1a86cd54cca3f066b19d42af961e4b85a6 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 03:54:03 -0600 Subject: [PATCH] Make big numbers readable, and make the export add up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeded a table that looks like a real game — "Mike from work", "Jean-Philippe", stacks from $12 to $3,200,000 — and measured what the layout does with it. ".00" was costing a third of a seat puck Every amount rendered with two forced decimal places. Measured, the trailing ".00" is ~22px, against the 64px a seat puck has to show a stack in. So "$1,250,000.00" needed 102px of a 64px box — and because a number has no spaces to break on, it neither wrapped nor ellipsised. It drew straight out over the seat next to it. Money now shows cents only when there are cents. A chip game deals in whole numbers nearly all the time, and "$12" says exactly what "$12.00" said in half the width. A quarter-blind game is untouched: $0.25, $12.50 and $0.75 pots all still read as before, checked against a 25c/50c table. That alone took "$1,250,000.00" down to "$1,250,000" — 102px to 80px, still wider than the 64px puck. The remaining overflow is handled by stepping the font down on long amounts, which keeps every digit. Abbreviating to "$1.25M" would have fit more comfortably and was the obvious move, but a dealer reading a rounded stack off the felt is a worse failure than a small one, and the exact figure is the entire point of the tool. Measured after: every stack now paints inside its own puck, nothing spills onto a neighbour. Because Money.format is the single formatter all 47 call sites go through, this lands on the seat pucks, action bar, pot breakdown, seat menu, standings and history at once — the payoff from consolidating it earlier. The CSV export couldn't be added up The file exists so a house manager or a disputed chip count has something that outlives the tab, and every money column in it was written as text: "$1,250,000.00". The commas were quoted correctly so the file always parsed — it just imported as strings, so the one thing anyone opens it to do, total a column, needed every cell cleaned first. Money columns now go in as bare numbers (1250000.00) with no symbol and no separators. The columns meant for humans stay human: names are still names, payouts still read "Alexandra: $1,250,000", and a name with a comma in it is still quoted. 12 new tests (88 total, all passing), including the case that would be ruined by dropping cents unconditionally, and a proper quote-aware CSV parser to check every money cell parses as a number. Re-ran the occlusion sweep from the previous round at 9 seats, 10 seats and tournament stacks: still clean, no regression from the font changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 18 +++ .../js/casino/holdem/handHistoryExport.js | 18 ++- .../wwwroot/js/casino/holdem/money.js | 20 ++- .../wwwroot/js/casino/holdem/render.js | 16 ++- tests/holdem/load.js | 2 + tests/holdem/money.test.js | 126 ++++++++++++++++++ 6 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 tests/holdem/money.test.js diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 90424e3..a7e6145 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1923,6 +1923,24 @@ body.hasActionBar #holdemSidebar { margin-top: 0.1rem; } +/* Tournament-sized amounts. A number has no spaces to break on, so an amount too + wide for the 64px puck neither wraps nor ellipsises — it draws straight out over + the seat next to it. These step the size down so every digit stays on its own + puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more + comfortably, but a dealer reading a rounded stack off the felt is a worse failure + than a small one, and the exact figure is what the tool is for. */ +.seatPuckStack.puckAmountLong, +.seatPuckTotal.puckAmountLong { + font-size: 0.5rem; + letter-spacing: -0.01em; +} + +.seatPuckStack.puckAmountHuge, +.seatPuckTotal.puckAmountHuge { + font-size: 0.44rem; + letter-spacing: -0.02em; +} + .blindsRow { display: flex; flex-wrap: wrap; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js index 571ad8a..9a63925 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js @@ -15,6 +15,10 @@ const HandHistoryExport = { } }, + // Money columns go in as bare numbers. Written as "$1,250,000.00" they import as + // text, so the one thing anybody opens this file to do — total a column and see + // who owes what — doesn't work without cleaning every cell first. The commas were + // quoted correctly, so the file parsed; it just wouldn't add up. toCsv(handHistory, standings = []) { const rows = [["Hand #", "Time", "Pot", "Payouts"]]; // handHistory is stored newest-first; the export reads better oldest-first. @@ -25,7 +29,7 @@ const HandHistoryExport = { rows.push([ entry.handNumber, this.formatTimestamp(entry.timestamp), - Money.format(entry.pot), + Money.forSheet(entry.pot), payouts, ]); }); @@ -41,12 +45,12 @@ const HandHistoryExport = { standings.forEach(row => { rows.push([ row.name, - Money.format(row.in), - Money.format(row.won), - Money.formatSigned(row.net), - Money.format(row.boughtIn || 0), - Money.format(row.chipsNow || 0), - Money.formatSigned(row.cashPosition || 0), + Money.forSheet(row.in), + Money.forSheet(row.won), + Money.forSheet(row.net), + Money.forSheet(row.boughtIn || 0), + Money.forSheet(row.chipsNow || 0), + Money.forSheet(row.cashPosition || 0), ]); }); } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js index 912709e..79b41fd 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/money.js @@ -25,9 +25,27 @@ const Money = { return this.toCents(amount) / 100; }, + // Cents only when there are cents. + // + // A chip game deals in whole numbers nearly all the time, and a trailing ".00" + // is two characters that say nothing — measured at ~22px, against the 64px a + // seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps; + // "$1,250,000" does. Amounts that genuinely carry cents still show them, so a + // 25c-blind game reads exactly as it did. format(amount) { const value = Number(amount) || 0; - return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + const hasCents = this.toCents(value) % 100 !== 0; + return `$${value.toLocaleString(undefined, { + minimumFractionDigits: hasCents ? 2 : 0, + maximumFractionDigits: 2, + })}`; + }, + + // The same amount for a spreadsheet rather than for a person: no currency symbol + // and no thousands separators, because those are what make a column of money + // import as text and refuse to add up. + forSheet(amount) { + return (this.toCents(amount) / 100).toFixed(2); }, // "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 92af29c..2e21b50 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -261,11 +261,14 @@ const Render = { const total = handTotal > 0 ? handTotal : HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount); - node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : ""; + const totalEl = node.querySelector(".seatPuckTotal"); + totalEl.textContent = total > 0 ? Money.format(total) : ""; + this.fitAmount(totalEl); const stackEl = node.querySelector(".seatPuckStack"); const stack = Betting.stackOf(this.state, player); stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : ""; + this.fitAmount(stackEl); node.classList.toggle("seatActing", index === this.state.actingSeatIndex); node.classList.toggle("seatEmpty", empty); @@ -274,6 +277,17 @@ const Render = { 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) { diff --git a/tests/holdem/load.js b/tests/holdem/load.js index 24ad5d9..f2662c2 100644 --- a/tests/holdem/load.js +++ b/tests/holdem/load.js @@ -18,6 +18,7 @@ const Money = load("money"); const PotCalculator = load("potCalculator"); const SplitCalculator = load("splitCalculator"); const Betting = load("betting"); +const HandHistoryExport = load("handHistoryExport"); // state.js exports `HoldemState`, which doesn't match its filename. const HoldemState = require(path.join(SRC, "state.js")); @@ -68,6 +69,7 @@ function contributions(state) { module.exports = { Money, + HandHistoryExport, PotCalculator, SplitCalculator, Betting, diff --git a/tests/holdem/money.test.js b/tests/holdem/money.test.js new file mode 100644 index 0000000..2e2da89 --- /dev/null +++ b/tests/holdem/money.test.js @@ -0,0 +1,126 @@ +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { Money, HandHistoryExport } = require("./load"); + +describe("Money.format", () => { + test("whole amounts drop the pointless cents", () => { + assert.equal(Money.format(12), "$12"); + assert.equal(Money.format(450), "$450"); + assert.equal(Money.format(1250000), "$1,250,000"); + assert.equal(Money.format(0), "$0"); + }); + + test("amounts that carry cents still show them", () => { + assert.equal(Money.format(12.5), "$12.50"); + assert.equal(Money.format(0.25), "$0.25"); + assert.equal(Money.format(3.01), "$3.01"); + }); + + test("a quarter-blind game reads exactly as it did", () => { + // The case that would be ruined by dropping cents unconditionally. + [0.25, 0.5, 1.75, 12.05].forEach(v => { + assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`); + }); + }); + + test("floating-point dust doesn't invent cents", () => { + assert.equal(Money.format(0.1 + 0.2), "$0.30"); + assert.equal(Money.format(1.0000000001), "$1"); + }); + + test("signed amounts put the sign out front, both scales", () => { + assert.equal(Money.formatSigned(87500), "+$87,500"); + assert.equal(Money.formatSigned(-87500), "-$87,500"); + assert.equal(Money.formatSigned(-12.5), "-$12.50"); + assert.equal(Money.formatSigned(0), "$0"); + }); + + test("rubbish in doesn't produce rubbish out", () => { + assert.equal(Money.format(null), "$0"); + assert.equal(Money.format(undefined), "$0"); + assert.equal(Money.format("nonsense"), "$0"); + }); +}); + +describe("Money.forSheet", () => { + test("a bare number a spreadsheet will add up", () => { + assert.equal(Money.forSheet(1250000), "1250000.00"); + assert.equal(Money.forSheet(12.5), "12.50"); + assert.equal(Money.forSheet(-87500), "-87500.00"); + }); + + test("no currency symbol and no thousands separators", () => { + const written = Money.forSheet(1234567.89); + assert.ok(!written.includes("$"), "a $ makes the cell text"); + assert.ok(!written.includes(","), "a comma makes the cell text"); + assert.equal(Number(written), 1234567.89, "and it parses straight back to a number"); + }); +}); + +describe("the exported CSV", () => { + const history = [ + { handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] }, + { handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] }, + ]; + const standings = [ + { id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 }, + { id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 }, + ]; + + // Splitting on commas isn't good enough: the timestamp column legitimately holds + // one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason. + const splitRow = line => { + const fields = []; + let field = ""; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quoted) { + if (c === '"' && line[i + 1] === '"') { field += '"'; i++; } + else if (c === '"') quoted = false; + else field += c; + } else if (c === '"') quoted = true; + else if (c === ",") { fields.push(field); field = ""; } + else field += c; + } + fields.push(field); + return fields; + }; + + test("every money cell parses as a number", () => { + const csv = HandHistoryExport.toCsv(history, standings); + const lines = csv.split("\r\n"); + + lines.slice(1, 3).forEach(line => { + const cell = splitRow(line)[2]; // Pot + assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`); + }); + + // Standings rows: every column after the name has to be numeric. + const standingsStart = lines.findIndex(l => l.startsWith("Player,")); + lines.slice(standingsStart + 1).filter(Boolean).forEach(line => { + splitRow(line).slice(1).forEach(cell => { + assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`); + }); + }); + }); + + test("a million-dollar pot doesn't need quoting any more", () => { + const csv = HandHistoryExport.toCsv(history, []); + assert.ok(csv.includes("1250000.00"), "written plainly"); + assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string"); + }); + + test("names and payout descriptions stay human", () => { + const csv = HandHistoryExport.toCsv(history, standings); + assert.ok(csv.includes("Alexandra"), "the name column is still a name"); + assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence"); + }); + + test("a name with a comma is still quoted properly", () => { + const csv = HandHistoryExport.toCsv([], [ + { id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 }, + ]); + assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed"); + }); +});