Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/js/casino/holdem/handHistoryExport.js
T
jheapsandClaude Opus 5 ab92259e3e Put the dealer's own notes in the file they hand over
The CSV export carries the hands, the standings and the settle-up. It carried neither
of the two places a dealer types context.

Each seat can hold custom fields -- "IOU: $20 to Ann", "Seat rent: paid to 2am",
"Venmo: @ann" -- and the table has a notes box for the rest: side bets, a high-hand
promo, who owes the box from last week. Both are typed precisely because they matter to
settling up, and both were visible only inside the tool. The one place the context was
written down was the one place it could not leave.

Now the standings rows end with a Notes column carrying that seat's fields, and the
table's notes go in a section of their own at the end.

  Player,Paid In,Won,Net,Bought In,Chips Now,Up/Down,Notes
  Ann,20.00,60.00,40.00,200.00,150.00,-50.00,Seat rent: paid to 2am; Venmo: @ann
  Ben,20.00,0.00,-20.00,200.00,200.00,0.00,IOU: $20 to Ann
  Cal,20.00,0.00,-20.00,200.00,250.00,50.00,

  Table Notes
  "High-hand promo until midnight.
  Mo owes the box $15, from last week."

Notes go last so the money columns keep the shape a spreadsheet expects, and the
multi-line note is quoted as one cell rather than becoming rows that read as more
players.

An existing test caught this, correctly

"every money cell parses as a number" asserted that every column after the name was
numeric, and a Notes column is text by definition. The invariant it protects is real --
the file exists so somebody can total a column -- so it now names the six money columns
and checks those, rather than "everything after the first". That is the assertion it
always meant.

9 tests (278 total): a seat's fields ride along, the money columns keep their order, a
seat with nothing written gets an empty cell rather than the word undefined, a blank
field does not leave a stray colon, a multi-line note survives as one cell, no notes
means no section rather than an empty heading, and asking for the standings does not
change the seats.

Driven through the real Export CSV button, not just the function: the downloaded file
carries both seats' notes and the table's. Audit and contrast clean, no console errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-01 19:52:20 -06:00

123 lines
5.5 KiB
JavaScript

// 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 "";
}
},
// 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 = [], { notes = "" } = {}) {
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.forSheet(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"]);
// 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.
//
// 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,
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),
row.notes || "",
]);
});
// And the answer the standings are read to get to. Whoever opens this file
// wants the payment list, not to redo the arithmetic in a spreadsheet.
const plan = SettleUp.plan(standings);
if (plan.payments.length > 0) {
rows.push([]);
rows.push(["Settle Up"]);
rows.push(["Pays", "Receives", "Amount"]);
plan.payments.forEach(payment => {
rows.push([payment.from, payment.to, Money.forSheet(payment.amount)]);
});
if (!plan.balanced) {
// The file is the paper trail, so the caveat travels with it rather
// than living only in a sidebar nobody exported.
rows.push([
plan.shortBy > 0
? `Unpaid: ${Money.forSheet(plan.shortBy)} — more chips in play than cash taken`
: `Unclaimed: ${Money.forSheet(plan.overBy)} — more cash taken than chips in play`,
]);
}
}
}
// 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");
},
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 = [], 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");
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;
}