diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index f848c08..65d9fdb 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -103,6 +103,7 @@
Session
+

Standings

diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index acb3a7b..1d7deca 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1095,6 +1095,19 @@ button, line-height: 1.5; } +/* Amber, like the bank check's disagreement — both are "your record of the night is + wrong and you can still do something about it". */ +.storageWarning { + margin: 0; + padding: 0.5rem 0.6rem; + border: 1px solid rgba(240, 180, 106, 0.45); + border-radius: 7px; + background: rgba(240, 180, 106, 0.08); + color: #f0b46a; + font-size: 0.76rem; + line-height: 1.4; +} + /* The shortcut legend. A touch device has no keys to press, so it doesn't get a panel telling it about them — the same pointer question the touch-target rules ask. */ @media (pointer: coarse) { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index e2be2ca..787d8c5 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -458,6 +458,16 @@ document.addEventListener("DOMContentLoaded", () => { // Escape is Modal's job now, for this sheet and the five others that never had it. const rulingsOverlay = document.getElementById("rulingsOverlay"); const closeRulings = () => { rulingsOverlay.hidden = true; }; + // 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. + HoldemState.onSaveFailed = () => { + Toast.show( + "This table isn't being saved — refreshing would lose it. Keep this tab open.", + { duration: 12000 }); + Render.renderStorageWarning(); + }; + // The shortcut legend, built from the same list the handlers are keyed off, so a // shortcut can't exist without a line here saying what it does. const legend = document.getElementById("shortcutLegend"); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 87171fb..8d57b9e 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -751,6 +751,21 @@ const Render = { this.renderBankReconciliation(); this.renderSettleUp(standings); + this.renderStorageWarning(); + }, + + // A toast is a moment; this table not being saved is a fact about the rest of the + // night. It sits at the top of the panel a dealer opens to check the money, and it + // stays there, because the raise pad dismisses toasts and the one time this warning + // matters is the one time it must not have been missed. + renderStorageWarning() { + const host = document.getElementById("storageWarning"); + if (!host) return; + host.hidden = !HoldemState.saveFailed; + 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."; }, // The last step the standings stopped short of. Six signed numbers at 2am is not diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js index 09e9aa7..b1abcdd 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/state.js @@ -122,12 +122,32 @@ const HoldemState = { } }, + // True once a save has failed. The UI reads this to keep saying so, because a + // toast is a moment and this is a fact about the rest of the night. + saveFailed: false, + + // Called the first time a save fails. Set by the UI. + onSaveFailed: null, + save(state) { try { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state)); - } catch { - // Storage full/unavailable (private browsing, etc.) — the table still works - // for the rest of the session, it just won't survive a refresh. + return true; + } catch (error) { + // Storage full, or private browsing. The table still works for the rest of + // the session — it just won't survive a refresh, and nothing used to say + // so. That is the same shape of fault the bank check exists for: silent + // until it is too late to do anything about it. A dealer who finds out by + // refreshing has lost the night's standings, and the only warning they got + // was the table being empty. + // + // Announced once. A dealer tapping a bar thirty times a hand does not need + // thirty of these. + if (!this.saveFailed) { + this.saveFailed = true; + if (typeof this.onSaveFailed === "function") this.onSaveFailed(error); + } + return false; } }, diff --git a/tests/holdem/saveFailure.test.js b/tests/holdem/saveFailure.test.js new file mode 100644 index 0000000..abe6c12 --- /dev/null +++ b/tests/holdem/saveFailure.test.js @@ -0,0 +1,95 @@ +// What happens when the browser refuses to save. +// +// The table keeps working — that was always true and is still the right behaviour. +// What was missing is anybody being told, which is the same shape of fault the bank +// check exists for: silent until it's too late to act on. +const test = require("node:test"); +const assert = require("node:assert"); +const { HoldemState, makeTable } = require("./load"); + +function withStorage(behaviour) { + const calls = []; + globalThis.localStorage = { + setItem(key, value) { + calls.push({ key, value }); + if (behaviour === "throw") { + const error = new Error("QuotaExceededError"); + error.name = "QuotaExceededError"; + throw error; + } + }, + getItem: () => null, + removeItem() {}, + }; + return calls; +} + +test.beforeEach(() => { + HoldemState.saveFailed = false; + HoldemState.onSaveFailed = null; +}); + +test.after(() => { + delete globalThis.localStorage; + HoldemState.saveFailed = false; + HoldemState.onSaveFailed = null; +}); + +test("a save that works says so and leaves no mark", () => { + withStorage("ok"); + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + + assert.equal(HoldemState.save(state), true); + assert.equal(HoldemState.saveFailed, false); +}); + +test("a save that fails says so rather than throwing", () => { + withStorage("throw"); + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + + assert.equal(HoldemState.save(state), false); + assert.equal(HoldemState.saveFailed, true); +}); + +test("the table is told once, not on every action", () => { + // A dealer taps the bar thirty times a hand and doesn't need thirty warnings. + withStorage("throw"); + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + let told = 0; + HoldemState.onSaveFailed = () => { told++; }; + + for (let i = 0; i < 20; i++) HoldemState.save(state); + + assert.equal(told, 1); +}); + +test("the failure is handed the error, so the reason can be reported later", () => { + withStorage("throw"); + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + let seen = null; + HoldemState.onSaveFailed = error => { seen = error; }; + + HoldemState.save(state); + + assert.equal(seen && seen.name, "QuotaExceededError"); +}); + +test("a failing save still doesn't stop the table", () => { + // The chips have already moved in memory; refusing to continue would be worse + // than not persisting. + withStorage("throw"); + const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 100 }); + state.seats[0].stack = 175; + + assert.doesNotThrow(() => HoldemState.save(state)); + assert.equal(state.seats[0].stack, 175); +}); + +test("no listener is not an error", () => { + withStorage("throw"); + const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 }); + HoldemState.onSaveFailed = null; + + assert.doesNotThrow(() => HoldemState.save(state)); + assert.equal(HoldemState.saveFailed, true); +});