Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01f5139740 |
@@ -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