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");
+ });
+});