From 2f4efd1cab388beb806da8f97ac52095411f29c5 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 30 Apr 2026 21:22:23 +0700 Subject: [PATCH] feat(tab-lock): single-tab guard via BroadcastChannel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tabs of the app on the same origin both running auto-call would double-draw, double-write `loto_master`, and overlap audio. New tab now broadcasts a claim; old tab freezes itself with a fullscreen overlay ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") and the user can tap to take it back — handover broadcasts a fresh claim, freezing the other tab in turn. Mounted in +layout.svelte's onMount so the cleanup closes the channel on HMR / route changes. No-op in legacy iOS Safari (≤15.4) without BroadcastChannel — silently falls through to the prior behavior. --- web/docs/codebase-summary.md | 1 + web/src/lib/tab-lock.svelte.js | 58 +++++++++++++++++++++++ web/src/lib/tab-lock.test.js | 87 ++++++++++++++++++++++++++++++++++ web/src/routes/+layout.svelte | 28 ++++++++++- 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/tab-lock.svelte.js create mode 100644 web/src/lib/tab-lock.test.js diff --git a/web/docs/codebase-summary.md b/web/docs/codebase-summary.md index 945854f..ef60dd0 100644 --- a/web/docs/codebase-summary.md +++ b/web/docs/codebase-summary.md @@ -23,6 +23,7 @@ |------|---------| | `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns, soft "no 3 consecutive filled cols per row" via rejection sampling), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. | | `src/lib/master-store.svelte.js` | Shared reactive `{called, remaining}` $state for the master deck, persisted to `loto_master`. Exports `masterState`, `loadMaster`, `saveMaster`, `startNewGame`, `drawNext`, `resetMaster`. Hydrated once via `+layout.svelte`'s `onMount` so player-side reads see the full history regardless of mount order. Replaced the single-slot `call-bus` to fix history-loss bugs (regen, reload, multi-tab) — see `plans/reports/code-reviewer-260430-2024-both-mode-consistency.md`. | +| `src/lib/tab-lock.svelte.js` | Single-tab guard via `BroadcastChannel`. New tab broadcasts `claim`; old tab sets `tabLock.frozen = true`. `+layout.svelte` mounts it on boot and renders a fullscreen overlay banner ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") when frozen — tap calls `reclaimTab` which broadcasts back, freezing the other tab in turn. No-op in browsers without `BroadcastChannel` (legacy iOS Safari ≤15.4). Prevents double auto-call intervals, double localStorage writers, and overlapping audio across tabs. | | `src/lib/player-auto-cross.js` | Pure `applyMasterCalls({grid, crossed, called, lastHandledIndex, manualUnticks, mode})`. Cursor-by-index dedup (vs the retired `at`-timestamp model), so any caller can pass `lastHandledIndex: 0` to replay master's full history (used by player regen + "Xoá đánh dấu" in both mode). Manual unticks suppress re-cross on replay. | | `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. | | `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `