Let the dealer find one hand in the log instead of scrolling the night
The hand log exists for one moment: somebody at the rail says they won a pot an hour ago and wants it looked up. A night runs to sixty hands or more and the panel showed five of them at a time, in a box fixed at 260px whether the dealer was on a phone or a 27" screen with room for twenty. There was no way to ask for a hand — only to scroll and read every row. A filter box over the list now takes a name, an amount or a hand number. Matching is loose on purpose: "fay" whatever case it's typed in, "190" for a $190 pot or a $190 payout, "$1,90" for the same typed badly, "#12" when the dealer knows the hand and wants that exact one. Beside it, how much of the night is hidden — "8 of 60" — since a filter matching one hand otherwise looks like a table that only played one. The list also takes the room it has: clamp(200px, 42vh, 560px) rather than a flat 260, which is seven or eight rows instead of five on a desktop and no worse on a phone. Export stays the whole night, always. A CSV quietly missing the hands scrolled past would be a record with holes in it and nothing on the file to say so — if a filter is up when it's exported, the toast says how many went in. Matching deliberately covers only what the row displays. Searching the full ledger as well was the first attempt, so the hand that broke somebody could be found under the loser's name — but the ledger holds everyone who put a chip in, and measured on a seven-handed night where nobody folded, "Fay" returned 60 of 60. A filter that searches text the panel doesn't show looks broken even when it's right. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -141,7 +141,13 @@
|
||||
</div>
|
||||
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<div class="handHistoryFilterRow">
|
||||
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
||||
placeholder="Find a hand — name, amount or #" aria-label="Filter the hand log by player, amount or hand number" />
|
||||
<span id="handHistoryCount" class="handHistoryCount" hidden></span>
|
||||
</div>
|
||||
<div id="handHistoryList"></div>
|
||||
<p id="handHistoryNoMatch" class="fieldHint" hidden></p>
|
||||
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
|
||||
|
||||
<div class="handHistoryActions">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user