Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95fd46ee99 | ||
|
|
4cc8faf8cb | ||
|
|
cfb83a2ba6 |
@@ -25,7 +25,6 @@
|
||||
<section id="tableSection">
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
|
||||
@@ -357,21 +357,18 @@ button,
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#tableWatermark {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.06);
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
/* The felt used to carry a "TEXAS HOLD'EM" wordmark across its middle. It came out
|
||||
from behind the pot badge in two pieces — measured at every seat count from two to
|
||||
ten, the middle 20% of it was covered, so the felt read "TEXAS LD'EM" — and no
|
||||
placement fixed that everywhere. Thirty per cent down cleared the badge on a desktop
|
||||
and landed on the seats of a phone's square felt (43-70% covered); a smaller mark at
|
||||
20% or 78% was clean on desktop and tablet and no better on a phone; the bottom one
|
||||
fell off the oval entirely at 360px. A square felt with six to nine seats has no
|
||||
clear band wide enough for a word.
|
||||
|
||||
So it's gone. A watermark that looks broken is worse than no watermark, and the felt
|
||||
has a gradient, a rim, an inner gold line and a shadow to be a table with — plus a
|
||||
header saying "Texas Hold'em Dealer" a few inches above it. */
|
||||
|
||||
#potBadge {
|
||||
position: relative;
|
||||
@@ -2845,10 +2842,6 @@ body.hasActionBar #toastHost {
|
||||
padding: 0.25rem 0.3rem;
|
||||
}
|
||||
|
||||
#tableWatermark {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
#potBadge {
|
||||
padding: 0.5rem 1.1rem;
|
||||
}
|
||||
|
||||
@@ -461,6 +461,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// The one time the browser refuses to save, say so out loud as well as leaving the
|
||||
// standing warning in the Session panel. Set before the first render so a failure
|
||||
// during start-up is caught too.
|
||||
// Another tab writing to the same table. Said once, with the way out attached —
|
||||
// reloading here adopts whatever that tab has done, which is the only resolution
|
||||
// that doesn't throw somebody's work away.
|
||||
HoldemState.onOtherTab = () => {
|
||||
Toast.show(
|
||||
"This table is open in another tab. Whichever acts last wins.",
|
||||
{ actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
|
||||
Render.renderStorageWarning();
|
||||
};
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
HoldemState.onSaveFailed = () => {
|
||||
Toast.show(
|
||||
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
|
||||
@@ -571,4 +582,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if ((state.handHistory || []).length === 0) return;
|
||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
||||
});
|
||||
|
||||
// The load happens on the first line of this file, long before there's a Toast to
|
||||
// show it in, so this is checked here rather than wired as a callback. The standing
|
||||
// warning in the Session panel doesn't need it — that reads the flag every render —
|
||||
// but an empty table is a thing a dealer needs told, not left to notice.
|
||||
if (HoldemState.loadFailed) {
|
||||
Toast.show(
|
||||
"The saved table couldn't be read, so this one started empty.",
|
||||
{ duration: 12000 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -761,11 +761,31 @@ const Render = {
|
||||
renderStorageWarning() {
|
||||
const host = document.getElementById("storageWarning");
|
||||
if (!host) return;
|
||||
host.hidden = !HoldemState.saveFailed;
|
||||
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
|
||||
if (host.hidden) return;
|
||||
host.textContent =
|
||||
"This table isn't being saved — the browser refused. It'll keep working, " +
|
||||
"but closing or refreshing this tab loses the session. Export CSV below keeps a copy.";
|
||||
|
||||
// Any of these can be true at once — a browser having trouble with storage
|
||||
// tends to have it in both directions — and the one that explains an empty
|
||||
// table goes first, because that's the question the dealer is standing there
|
||||
// asking.
|
||||
const lines = [];
|
||||
if (HoldemState.otherTabActive) {
|
||||
lines.push(
|
||||
"This table is open in another tab, and both are writing to it — " +
|
||||
"whichever acts last wins, so work done in the other one can vanish. " +
|
||||
"Close one, then reload this tab to pick up the table as it stands.");
|
||||
}
|
||||
if (HoldemState.loadFailed) {
|
||||
lines.push(
|
||||
"The saved table couldn't be read, so this one started empty. " +
|
||||
"The unreadable copy has been kept rather than written over.");
|
||||
}
|
||||
if (HoldemState.saveFailed) {
|
||||
lines.push(
|
||||
"This table isn't being saved — the browser refused. It'll keep working, " +
|
||||
"but closing or refreshing this tab loses the session. Export CSV below keeps a copy.");
|
||||
}
|
||||
host.textContent = lines.join(" ");
|
||||
},
|
||||
|
||||
// The last step the standings stopped short of. Six signed numbers at 2am is not
|
||||
|
||||
@@ -81,12 +81,69 @@ const HoldemState = {
|
||||
};
|
||||
},
|
||||
|
||||
// Where an unreadable save is kept instead of being written over.
|
||||
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
|
||||
|
||||
// True once another tab has written to this table. Two tabs each hold their own
|
||||
// copy in memory and each save writes the whole thing, so the second one to act
|
||||
// silently throws the first one's work away — measured: tab A deals and takes a
|
||||
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
|
||||
// nothing said in either tab.
|
||||
//
|
||||
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
|
||||
// would be its own hazard; knowing there are two of them is what they need.
|
||||
otherTabActive: false,
|
||||
onOtherTab: null,
|
||||
|
||||
// The browser fires this in every *other* tab, never the one that wrote, so our
|
||||
// own saves can't trip it.
|
||||
watchOtherTabs() {
|
||||
if (typeof window === "undefined" || this.tabWatcher) return;
|
||||
this.tabWatcher = event => {
|
||||
if (event.key !== this.STORAGE_KEY) return;
|
||||
if (this.otherTabActive) return;
|
||||
this.otherTabActive = true;
|
||||
if (typeof this.onOtherTab === "function") this.onOtherTab();
|
||||
};
|
||||
window.addEventListener("storage", this.tabWatcher);
|
||||
},
|
||||
|
||||
// True when there was a saved table and it couldn't be read. Same pair as
|
||||
// saveFailed above, for the same reason: the UI has to be able to say so.
|
||||
loadFailed: false,
|
||||
onLoadFailed: null,
|
||||
|
||||
// A saved table that won't parse becomes an empty table, which is the only thing
|
||||
// it can become — but it used to become one silently, and then the first edit
|
||||
// saved over the top of it. A dealer mid-session saw an empty felt with no idea
|
||||
// whether they'd mis-tapped Reset or the browser had lost the night, and whatever
|
||||
// a person might have picked out of the raw JSON was gone a tap later.
|
||||
//
|
||||
// So the unreadable value is moved aside first. It is not offered as a restore —
|
||||
// half a JSON document isn't a table, and pretending otherwise would be worse than
|
||||
// an empty one — but it stops being destroyed, and somebody who wants the
|
||||
// standings out of it still can.
|
||||
failedLoad(raw) {
|
||||
if (!this.loadFailed) {
|
||||
this.loadFailed = true;
|
||||
try {
|
||||
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
|
||||
} catch {
|
||||
// No room to keep it. Nothing more to try, and the warning below is
|
||||
// the part that actually matters.
|
||||
}
|
||||
if (typeof this.onLoadFailed === "function") this.onLoadFailed();
|
||||
}
|
||||
return this.createDefault();
|
||||
},
|
||||
|
||||
load() {
|
||||
let raw = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return this.createDefault();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
|
||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||
|
||||
@@ -118,7 +175,8 @@ const HoldemState = {
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return this.createDefault();
|
||||
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||
return this.failedLoad(raw);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// What happens when the saved table can't be read.
|
||||
//
|
||||
// An unreadable save can only become an empty table. What it must not do is become
|
||||
// one silently and then get written over by the first edit.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState } = require("./load");
|
||||
|
||||
function withStored(value, { readOnly = false } = {}) {
|
||||
const store = new Map();
|
||||
if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value);
|
||||
globalThis.localStorage = {
|
||||
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||
setItem(key, val) {
|
||||
if (readOnly) throw new Error("QuotaExceededError");
|
||||
store.set(key, val);
|
||||
},
|
||||
removeItem: key => store.delete(key),
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
HoldemState.loadFailed = false;
|
||||
HoldemState.onLoadFailed = null;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.localStorage;
|
||||
HoldemState.loadFailed = false;
|
||||
HoldemState.onLoadFailed = null;
|
||||
});
|
||||
|
||||
test("nothing stored is a new table, not a failure", () => {
|
||||
withStored(undefined);
|
||||
const state = HoldemState.load();
|
||||
|
||||
assert.equal(HoldemState.loadFailed, false);
|
||||
assert.ok(Array.isArray(state.seats));
|
||||
});
|
||||
|
||||
test("a good save loads and reports nothing", () => {
|
||||
const store = withStored(undefined);
|
||||
const original = HoldemState.createDefault();
|
||||
original.handNumber = 34;
|
||||
HoldemState.save(original);
|
||||
|
||||
const state = HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, false);
|
||||
assert.equal(state.handNumber, 34);
|
||||
assert.equal(store.has(HoldemState.RECOVERY_KEY), false);
|
||||
});
|
||||
|
||||
test("a truncated save is reported rather than silently becoming an empty table", () => {
|
||||
const good = JSON.stringify(HoldemState.createDefault());
|
||||
withStored(good.slice(0, Math.floor(good.length * 0.7)));
|
||||
|
||||
const state = HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
assert.ok(Array.isArray(state.seats), "it still hands back a usable table");
|
||||
});
|
||||
|
||||
test("valid JSON of the wrong shape counts as unreadable too", () => {
|
||||
withStored(JSON.stringify({ hello: "world" }));
|
||||
|
||||
HoldemState.load();
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
});
|
||||
|
||||
test("the unreadable copy is kept instead of being written over", () => {
|
||||
// This is the half that matters: whatever a person could have picked out of the
|
||||
// raw JSON used to be gone the moment the dealer touched a seat.
|
||||
const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"';
|
||||
const store = withStored(broken);
|
||||
|
||||
HoldemState.load();
|
||||
|
||||
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||
});
|
||||
|
||||
test("a later save doesn't touch the copy that was set aside", () => {
|
||||
const broken = '{"seats":[{"name":"Ann"';
|
||||
const store = withStored(broken);
|
||||
|
||||
const state = HoldemState.load();
|
||||
state.handNumber = 2;
|
||||
HoldemState.save(state);
|
||||
|
||||
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||
assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken);
|
||||
});
|
||||
|
||||
test("the listener fires once, not on every read", () => {
|
||||
withStored("{not json");
|
||||
let told = 0;
|
||||
HoldemState.onLoadFailed = () => { told++; };
|
||||
|
||||
HoldemState.load();
|
||||
HoldemState.load();
|
||||
HoldemState.load();
|
||||
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("no room to keep the copy is not a crash", () => {
|
||||
// Storage that refuses writes is exactly the storage most likely to hand back a
|
||||
// half-written value in the first place.
|
||||
withStored("{not json", { readOnly: true });
|
||||
|
||||
let state;
|
||||
assert.doesNotThrow(() => { state = HoldemState.load(); });
|
||||
assert.ok(Array.isArray(state.seats));
|
||||
assert.equal(HoldemState.loadFailed, true);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Noticing that the same table is open twice.
|
||||
//
|
||||
// Two tabs each hold their own copy in memory and each save writes the whole thing,
|
||||
// so the second one to act throws the first one's work away. This doesn't merge them
|
||||
// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
|
||||
// notices, which is what lets them decide.
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState } = require("./load");
|
||||
|
||||
function withWindow() {
|
||||
const listeners = [];
|
||||
globalThis.window = {
|
||||
addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
|
||||
removeEventListener(type, fn) {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
listeners,
|
||||
fire(key) { listeners.forEach(fn => fn({ key })); },
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
HoldemState.otherTabActive = false;
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.tabWatcher = null;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete globalThis.window;
|
||||
HoldemState.otherTabActive = false;
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.tabWatcher = null;
|
||||
});
|
||||
|
||||
test("a write to this table from elsewhere is noticed", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
win.fire(HoldemState.STORAGE_KEY);
|
||||
|
||||
assert.equal(HoldemState.otherTabActive, true);
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("somebody else's storage key is not our business", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
win.fire("some.other.app.state");
|
||||
win.fire(HoldemState.RECOVERY_KEY);
|
||||
|
||||
assert.equal(HoldemState.otherTabActive, false);
|
||||
assert.equal(told, 0);
|
||||
});
|
||||
|
||||
test("told once, however many times the other tab acts", () => {
|
||||
const win = withWindow();
|
||||
let told = 0;
|
||||
HoldemState.onOtherTab = () => { told++; };
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
|
||||
|
||||
assert.equal(told, 1);
|
||||
});
|
||||
|
||||
test("watching twice doesn't listen twice", () => {
|
||||
const win = withWindow();
|
||||
HoldemState.watchOtherTabs();
|
||||
HoldemState.watchOtherTabs();
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
assert.equal(win.listeners.length, 1);
|
||||
});
|
||||
|
||||
test("no listener set is not an error", () => {
|
||||
const win = withWindow();
|
||||
HoldemState.onOtherTab = null;
|
||||
HoldemState.watchOtherTabs();
|
||||
|
||||
assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
|
||||
assert.equal(HoldemState.otherTabActive, true);
|
||||
});
|
||||
|
||||
test("no window at all is not an error", () => {
|
||||
// The same modules are loaded in Node by the test harness.
|
||||
delete globalThis.window;
|
||||
HoldemState.tabWatcher = null;
|
||||
|
||||
assert.doesNotThrow(() => HoldemState.watchOtherTabs());
|
||||
assert.equal(HoldemState.otherTabActive, false);
|
||||
});
|
||||
@@ -180,6 +180,12 @@ const LayoutAudit = {
|
||||
collapsedColumns() {
|
||||
const offenders = [];
|
||||
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
|
||||
// Nothing inside something the page has hidden. A hidden element's children
|
||||
// all measure zero wide, so the standings header — which is hidden along
|
||||
// with its table until somebody has bought in — was reported as three
|
||||
// collapsed columns on every empty table. A column nobody can see hasn't
|
||||
// collapsed; it isn't there.
|
||||
if (row.closest("[hidden]")) return;
|
||||
if (getComputedStyle(row).display !== "grid") return;
|
||||
[...row.children].forEach(cell => {
|
||||
if (getComputedStyle(cell).display === "none") return;
|
||||
|
||||
Reference in New Issue
Block a user