diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index ae5cbb5..6c07afd 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -141,7 +141,13 @@

Hands

+
+ + +
+

Resolved pots are logged here, in case a chip count is disputed later.

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index a41d98e..0df9471 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1957,11 +1957,52 @@ button, color: #e07b7b; } +.handHistoryFilterRow { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.45rem; +} + +.handHistoryFilter { + flex: 1 1 auto; + min-width: 0; + /* 16px or iOS zooms the whole page on focus, which on a phone at the table throws + the felt off screen mid-hand. Every other field in this panel is held at 16px + for the same reason. */ + font-size: 16px; + padding: 0.35rem 0.5rem; + background: #141414; + border: 1px solid #2c2c2c; + border-radius: 6px; + color: #e6e6e6; +} + +.handHistoryFilter::placeholder { + color: #8a8a8a; +} + +.handHistoryFilter:focus-visible { + outline: 2px solid #d8b46a; + outline-offset: 1px; +} + +.handHistoryCount { + flex: 0 0 auto; + font-size: 0.72rem; + color: #8a8a8a; + font-variant-numeric: tabular-nums; +} + #handHistoryList { display: flex; flex-direction: column; gap: 0.4rem; - max-height: 260px; + /* Was a flat 260px, which is about four and a half rows — the same four and a half + whether the dealer is on a phone or a 27" screen with room for twenty. The floor + keeps it honest on a short window; the ceiling stops the panel from running away + on a tall one and pushing everything below it out of reach. */ + max-height: clamp(200px, 42vh, 560px); overflow-y: auto; } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 95dd018..a87c244 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -591,15 +591,33 @@ document.addEventListener("DOMContentLoaded", () => { if (!ok) return; HoldemState.clearHistory(state); + Render.historyFilter = ""; + document.getElementById("handHistoryFilter").value = ""; hooks.save(state); Render.renderAll(); Toast.show("Hand history cleared.", { duration: 2500 }); }); + // The filter is how the dealer looks at the log, not something about the table, so + // it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other + // tab. Clearing the history clears it too, since there is nothing left to filter. + document.getElementById("handHistoryFilter").addEventListener("input", e => { + Render.historyFilter = e.target.value; + Render.renderHandHistory(); + }); + document.getElementById("exportHistoryBtn").addEventListener("click", () => { if ((state.handHistory || []).length === 0) return; + // Always the whole night. A filter is for reading the panel; an export that + // quietly dropped the hands scrolled past would be a record with holes in it, + // and nothing on the CSV would say so. HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state), { notes: state.notes }); + if (Render.historyFilter) { + Toast.show(`Exported all ${state.handHistory.length} hands, not just the ${ + state.handHistory.filter(h => Render.handMatchesFilter(h, Render.historyFilter)).length} shown.`, + { duration: 3500 }); + } }); // 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/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index b413975..100a5a8 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -967,9 +967,50 @@ const Render = { newest ? newest.handNumber : "", newest ? newest.timestamp : "", newest ? newest.pot : "", + // Typing in the filter changes the list without changing the history, and + // the redraw is skipped when the signature matches — leave this out and the + // box takes input and does nothing. + this.historyFilter || "", ].join("|"); }, + // What the dealer opens the log for is one hand: the one somebody is arguing + // about. A night runs to sixty or more, five at a time in the panel, so finding it + // meant scrolling and reading every row. A name, an amount, or a hand number + // narrows it instead. + // + // Matching is deliberately loose. "fay" finds Fay whatever case it's typed in; + // "190" finds a $190 pot, a $190 payout, or hand 190, because a dealer half + // remembering an amount shouldn't have to know which of those they mean; "#12" + // pins it to the hand number, for when they do. + handMatchesFilter(entry, query) { + if (!query) return true; + const term = query.trim().toLowerCase(); + if (!term) return true; + + const handNumber = String(entry.handNumber || ""); + if (term.startsWith("#")) return handNumber.startsWith(term.slice(1)); + + // Only what the row itself shows. Matching the hand's full ledger as well was + // the first attempt, on the reasoning that a dealer might hunt for the hand + // that broke somebody by the loser's name — but the ledger holds everyone who + // put a chip in, so at a table where nobody folds it matched every hand. + // Measured on a seven-handed night: "Fay" returned 60 of 60. Searching text the + // panel doesn't display is a filter that looks broken even when it's right. + const labels = (entry.payouts || []).map(p => String(p.label || "").toLowerCase()).join(" "); + if (labels.includes(term)) return true; + + // Amounts, matched on the digits so "$190", "190" and "1,90" all behave. + const digits = term.replace(/[^0-9.]/g, ""); + if (digits) { + const amounts = [entry.pot, ...(entry.payouts || []).map(p => p.amount)] + .map(a => String(Money.round(Number(a) || 0))); + if (amounts.some(a => a.startsWith(digits))) return true; + if (handNumber === digits) return true; + } + return false; + }, + // Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a // full re-render runs after every fold, call and raise. The log only changes when a // hand is settled or the history is cleared, perhaps once every thirty taps, so it @@ -988,10 +1029,29 @@ const Render = { emptyHint.hidden = history.length > 0; document.getElementById("exportHistoryBtn").disabled = history.length === 0; + const query = this.historyFilter || ""; + const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history; + + // How much of the night is being hidden. Without it a filter that matches one + // hand looks exactly like a table that only played one, which is the sort of + // thing a dealer reads wrong at 2am with somebody leaning over the rail. + const count = document.getElementById("handHistoryCount"); + if (count) { + count.hidden = !query || history.length === 0; + count.textContent = `${shown.length} of ${history.length}`; + } + const noMatch = document.getElementById("handHistoryNoMatch"); + if (noMatch) { + noMatch.hidden = !(query && history.length > 0 && shown.length === 0); + noMatch.textContent = `No hand matches “${query.trim()}”. The log still has all ${history.length}.`; + } + const filterBox = document.getElementById("handHistoryFilter"); + if (filterBox) filterBox.hidden = history.length === 0; + // Built off-document so the panel is laid out once instead of once per row. const fragment = document.createDocumentFragment(); const template = document.getElementById("handHistoryRowTemplate"); - history.forEach(entry => { + shown.forEach(entry => { const row = template.content.firstElementChild.cloneNode(true); row.querySelector(".handHistoryHandNumber").textContent = `Hand #${entry.handNumber}`; row.querySelector(".handHistoryPot").textContent = Money.format(entry.pot); diff --git a/tests/holdem/handHistoryFilter.test.js b/tests/holdem/handHistoryFilter.test.js new file mode 100644 index 0000000..5bc33c3 --- /dev/null +++ b/tests/holdem/handHistoryFilter.test.js @@ -0,0 +1,123 @@ +// Finding one hand in a night of them. +// +// What the log is actually opened for is a single hand — the one somebody at the rail +// is arguing about. A night runs to sixty or more, five at a time in the panel, and +// there was no way to ask for one: the dealer scrolled and read every row. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Render } = require("./load"); + +const hand = (handNumber, pot, payouts, ledger = []) => ({ + handNumber, pot, timestamp: Date.now(), payouts, ledger, +}); + +// A night as the tool records it: labels carry the cards the wizard was shown. +const NIGHT = [ + hand(1, 40, [{ label: "Ann (A♠ K♠)", amount: 40 }], [{ name: "Ann", in: 20 }, { name: "Ben", in: 20 }]), + hand(2, 190, [{ label: "Fay", amount: 190 }], [{ name: "Fay", in: 95 }, { name: "Gus", in: 95 }]), + hand(12, 19, [{ label: "Ben", amount: 19 }], [{ name: "Ben", in: 10 }, { name: "Ann", in: 9 }]), + hand(120, 76, [{ label: "Cal", amount: 38 }, { label: "Dee", amount: 38 }], [{ name: "Cal" }, { name: "Dee" }]), +]; + +const found = query => NIGHT.filter(h => Render.handMatchesFilter(h, query)).map(h => h.handNumber); + +describe("no filter", () => { + test("shows the whole night", () => { + assert.deepEqual(found(""), [1, 2, 12, 120]); + assert.deepEqual(found(" "), [1, 2, 12, 120]); + assert.deepEqual(found(null), [1, 2, 12, 120]); + }); +}); + +describe("by name", () => { + test("finds the hand somebody won", () => { + assert.deepEqual(found("Fay"), [2]); + }); + + test("case doesn't matter — nobody types a capital at 2am", () => { + assert.deepEqual(found("fay"), [2]); + assert.deepEqual(found("FAY"), [2]); + }); + + test("a name inside a label with cards on it still matches", () => { + // The wizard writes the label as "Ann (A♠ K♠)", so the name is a substring + // rather than the whole cell. + assert.deepEqual(found("ann"), [1]); + }); + + test("matches what the row shows, not everyone who was in the hand", () => { + // Gus is in hand 2's ledger — he paid for it — but the row names the winner, + // and searching text the panel doesn't display is a filter that looks broken + // even when it's right. Measured on a seven-handed night where nobody folded, + // matching the ledger returned 60 of 60 for every name at the table. + assert.deepEqual(found("gus"), []); + }); + + test("a split pot is found by either winner", () => { + assert.deepEqual(found("cal"), [120]); + assert.deepEqual(found("dee"), [120]); + }); + + test("a name nobody has finds nothing rather than everything", () => { + assert.deepEqual(found("zed"), []); + }); +}); + +describe("by amount", () => { + test("finds a pot", () => { + assert.deepEqual(found("190"), [2]); + }); + + test("a dollar sign and commas are ignored", () => { + assert.deepEqual(found("$190"), [2]); + assert.deepEqual(found("$1,90"), [2], "typed badly, still found"); + }); + + test("finds a payout that isn't the whole pot", () => { + assert.deepEqual(found("38"), [120], "half of a chopped $76"); + }); + + test("a partial amount narrows rather than missing", () => { + // Half remembering "something around forty" should still get there. + assert.deepEqual(found("4"), [1], "$40"); + assert.deepEqual(found("19"), [2, 12], "the $190 pot and the $19 one"); + }); + + test("a bare number is an exact hand number, not a prefix of one", () => { + // Amounts match on their leading digits, because half a remembered figure is + // still a lead. Hand numbers don't: typing 12 to mean the hand is a dealer who + // knows which hand they want, and dragging 120 and 128 along with it is noise. + // "#12" is there for when they do want the prefix. + assert.ok(!found("12").includes(120)); + }); +}); + +describe("by hand number", () => { + test("a bare number matches the hand number as well as amounts", () => { + assert.ok(found("12").includes(12)); + }); + + test("a hash pins it to the hand number and nothing else", () => { + assert.deepEqual(found("#12"), [12, 120], "12 and 120 both start with 12"); + assert.deepEqual(found("#2"), [2], "and not the $190 pot of hand 2's neighbours"); + }); + + test("a hash with a number nobody played finds nothing", () => { + assert.deepEqual(found("#7"), []); + }); +}); + +describe("odd input can't break it", () => { + test("punctuation on its own doesn't match everything", () => { + assert.deepEqual(found("$"), [], "no digits and no letters left to match on"); + assert.deepEqual(found("#"), [1, 2, 12, 120], "an empty hand number is a prefix of them all"); + }); + + test("an entry with pieces missing doesn't throw", () => { + const broken = { handNumber: 5, pot: 10 }; + assert.doesNotThrow(() => Render.handMatchesFilter(broken, "ann")); + assert.equal(Render.handMatchesFilter(broken, "ann"), false); + assert.equal(Render.handMatchesFilter(broken, "10"), true, "the pot is still findable"); + }); +});