diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js index 4a3e4db..0257d3f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js @@ -19,7 +19,7 @@ const HandHistoryExport = { // 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 = []) { + toCsv(handHistory, standings = [], { notes = "" } = {}) { const rows = [["Hand #", "Time", "Pot", "Payouts"]]; // handHistory is stored newest-first; the export reads better oldest-first. [...handHistory].reverse().forEach(entry => { @@ -41,7 +41,12 @@ const HandHistoryExport = { rows.push(["Session Standings"]); // Bought in and chips now are what a cash-out is settled from; paid in and // won describe the hands. Both belong in the file the dealer hands over. - rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"]); + // + // Notes go last so the money columns keep the shape a spreadsheet expects. + // "IOU: $20 to Ann" is exactly the kind of thing that has to be in the file + // somebody settles up from, and it was only ever visible inside that seat's + // own editor. + rows.push(["Player", "Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down", "Notes"]); standings.forEach(row => { rows.push([ row.name, @@ -51,6 +56,7 @@ const HandHistoryExport = { Money.forSheet(row.boughtIn || 0), Money.forSheet(row.chipsNow || 0), Money.forSheet(row.cashPosition || 0), + row.notes || "", ]); }); @@ -76,6 +82,17 @@ const HandHistoryExport = { } } + // The table's own notes — side bets, IOUs, the promo running until midnight. + // The dealer typed them because they matter to the night, and the export is the + // thing that outlives the browser tab; leaving them behind meant the one place + // the context was written down was the one place it couldn't be handed over. + // csvField quotes the line breaks, so a multi-line note survives as one cell. + if (notes && notes.trim()) { + rows.push([]); + rows.push(["Table Notes"]); + rows.push([notes.trim()]); + } + return rows.map(row => row.map(f => this.csvField(f)).join(",")).join("\r\n"); }, @@ -86,8 +103,8 @@ const HandHistoryExport = { return `holdem-hand-history-${stamp}.csv`; }, - download(handHistory, standings = []) { - const csv = this.toCsv(handHistory, standings); + download(handHistory, standings = [], options = {}) { + const csv = this.toCsv(handHistory, standings, options); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index c30a69a..d2a1da3 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -580,7 +580,8 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("exportHistoryBtn").addEventListener("click", () => { if ((state.handHistory || []).length === 0) return; - HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state)); + HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state), + { notes: state.notes }); }); // The load happens on the first line of this file, long before there's a Toast to diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index e53cfbe..4228ba4 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -532,6 +532,14 @@ const HoldemState = { // window is almost always. const inFrontOfThem = seat ? (Number(seat.amountOverride) || 0) + (Number(seat.committed) || 0) : 0; + // Whatever the dealer wrote against this seat — "IOU: $20 to Ann", + // "Venmo: @ann". It's carried here so the export can hand it over with + // the money it belongs to; the standings panel itself doesn't show it. + const notes = (seat && Array.isArray(seat.customFields) ? seat.customFields : []) + .filter(field => (field.label || "").trim() || (field.value || "").trim()) + .map(field => `${(field.label || "").trim()}: ${(field.value || "").trim()}`.replace(/^: | :$/, "")) + .join("; "); + return { ...t, in: Money.round(t.in), @@ -540,6 +548,7 @@ const HoldemState = { boughtIn, chipsNow, cashPosition: Money.round(chipsNow + inFrontOfThem - boughtIn), + notes, }; }) .sort((a, b) => b.net - a.net); diff --git a/tests/holdem/exportNotes.test.js b/tests/holdem/exportNotes.test.js new file mode 100644 index 0000000..a0769a6 --- /dev/null +++ b/tests/holdem/exportNotes.test.js @@ -0,0 +1,97 @@ +// The things the dealer wrote down, in the file they hand over. +// +// The export already carried the hands, the standings and the settle-up. It didn't +// carry either of the two places a dealer types context — the per-seat fields and the +// table's own notes — so the one place an IOU was written down was the one place it +// couldn't leave. +const test = require("node:test"); +const assert = require("node:assert"); +const { HandHistoryExport, HoldemState, makeTable } = require("./load"); + +const tableWithNotes = () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 }); + ["Ann", "Ben", "Cal"].forEach((name, i) => { + state.seats[i].name = name; + state.seats[i].buyIns = 200; + }); + state.seats[0].customFields = [ + { label: "Seat rent", value: "paid to 2am" }, + { label: "Venmo", value: "@ann" }, + ]; + state.seats[1].customFields = [{ label: "IOU", value: "$20 to Ann" }]; + return state; +}; + +test("a seat's own fields ride along with its standings row", () => { + const state = tableWithNotes(); + const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state)); + + assert.match(csv, /Seat rent: paid to 2am; Venmo: @ann/); + assert.match(csv, /IOU: \$20 to Ann/); +}); + +test("the money columns keep their order, with notes on the end", () => { + const state = tableWithNotes(); + const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state)); + + assert.match(csv, /Player,Paid In,Won,Net,Bought In,Chips Now,Up\/Down,Notes/); +}); + +test("a seat with nothing written against it gets an empty cell, not the word undefined", () => { + const state = tableWithNotes(); + const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state)); + const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,")); + + assert.ok(calRow, "Cal should have a row"); + assert.ok(calRow.endsWith(","), `expected an empty last cell, got ${calRow}`); + assert.ok(!/undefined|null/.test(calRow)); +}); + +test("a blank label or value doesn't produce a stray colon", () => { + const state = tableWithNotes(); + state.seats[2].customFields = [{ label: "Note", value: "" }, { label: "", value: "" }]; + const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state)); + const calRow = csv.split("\r\n").find(line => line.startsWith("Cal,")); + + assert.match(calRow, /Note/); + assert.ok(!/;\s*;/.test(calRow), "an entirely blank field shouldn't take a slot"); +}); + +test("the table's notes are in the file", () => { + const state = tableWithNotes(); + state.notes = "High-hand promo until midnight."; + const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: state.notes }); + + assert.match(csv, /Table Notes/); + assert.match(csv, /High-hand promo until midnight\./); +}); + +test("a multi-line note survives as one cell", () => { + // csvField quotes anything with a line break in it, so the note doesn't turn into + // rows a spreadsheet reads as more players. + const state = tableWithNotes(); + const notes = "Line one\nLine two, with a comma\nLine \"three\""; + const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes }); + + assert.match(csv, /"Line one\nLine two, with a comma\nLine ""three"""/); +}); + +test("no notes means no section, not an empty heading", () => { + const state = tableWithNotes(); + const csv = HandHistoryExport.toCsv([], HoldemState.sessionStandings(state), { notes: " " }); + + assert.ok(!/Table Notes/.test(csv)); +}); + +test("the export still works with no standings at all", () => { + assert.doesNotThrow(() => HandHistoryExport.toCsv([], [], { notes: "just a note" })); + const csv = HandHistoryExport.toCsv([], [], { notes: "just a note" }); + assert.match(csv, /Table Notes/); +}); + +test("asking for the standings doesn't change the seats", () => { + const state = tableWithNotes(); + const before = JSON.stringify(state.seats); + HoldemState.sessionStandings(state); + assert.equal(JSON.stringify(state.seats), before); +}); diff --git a/tests/holdem/money.test.js b/tests/holdem/money.test.js index 2e2da89..a906e41 100644 --- a/tests/holdem/money.test.js +++ b/tests/holdem/money.test.js @@ -96,11 +96,26 @@ describe("the exported CSV", () => { 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. + // Standings rows: the money columns have to be numeric. Named rather than + // "everything after the first", which is what this used to say — the row now + // ends with a Notes column carrying whatever the dealer typed against the seat, + // and that is text by definition. The point of this test is that a column + // somebody totals adds up, so it checks the columns somebody totals. const standingsStart = lines.findIndex(l => l.startsWith("Player,")); + const header = splitRow(lines[standingsStart]); + const moneyColumns = ["Paid In", "Won", "Net", "Bought In", "Chips Now", "Up/Down"] + .map(name => { + const index = header.indexOf(name); + assert.ok(index > 0, `the standings header should still have a ${name} column`); + return index; + }); + 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`); + const cells = splitRow(line); + moneyColumns.forEach(index => { + const cell = cells[index]; + assert.ok(cell !== "" && !Number.isNaN(Number(cell)), + `standings cell "${cell}" in column ${header[index]} isn't a number`); }); }); });