From a2ef26adf7ab503b388eaffa219e4d1b6496c381 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:15:56 +0000 Subject: [PATCH] Keep several open tabs from overwriting each other's saves A tab takes the state another tab saves (storage event) and drops its own pending save. Only the tab that last started counting counts play time; it pauses the others over a BroadcastChannel. Decision 024. Closes #22 Co-Authored-By: Claude Opus 5.5 --- docs/decisions/024-several-tabs.md | 19 ++++++++++++++ docs/decisions/README.md | 1 + src/lib/clock.svelte.ts | 17 ++++++++++-- src/lib/persistence.test.ts | 42 ++++++++++++++++++++++++++++++ src/lib/persistence.ts | 33 +++++++++++++++++------ src/lib/store.svelte.ts | 13 ++++++++- 6 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 docs/decisions/024-several-tabs.md diff --git a/docs/decisions/024-several-tabs.md b/docs/decisions/024-several-tabs.md new file mode 100644 index 0000000..0604080 --- /dev/null +++ b/docs/decisions/024-several-tabs.md @@ -0,0 +1,19 @@ +# 024: Several LSOS tabs open at once + +2026-10-15, issue #22. Builds on decisions 003 and 006. + +## Decision + +- **Saves follow the latest tab.** When another tab saves, the browser's `storage` event hands + its state to every other tab, which takes it and drops its own pending save. So no tab + overwrites a newer save with an older copy. A newer saved version, or unreadable data, blocks + saving in the other tabs as it does on load. +- **One tab counts play time.** A tab that starts counting (resume, or opening with "track on + open") tells the others over a `BroadcastChannel`, and they pause their play clock. Otherwise + every open tab would add the same play time to the trackers. + +## Why + +These are the usual browser mechanisms for keeping tabs in step; both tabs stay usable, unlike +making a second tab read-only. A change made in the last half second before another tab's save +can be lost, which is small next to losing whole sessions. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 3c6ec83..ffe5714 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -34,3 +34,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [021](021-no-heist-tracker.md) No heist cooldown tracker: only Cayo Perico has one worth a timer; Heists tab removed - [022](022-real-time-timers.md) Timers tab on real time: Lucky Wheel (24 h) and Mansion production boost (×3 for 24 h, 24 h cooldown, not the same business twice in a row; with the Acid Lab boost: est.) - [023](023-overview.md) Overview tab: trackers give a shared `NextEvent`, due first then soonest, supplies on their way counted in the forecast, links that scroll to the card, quick actions with undo on every ready item, popularity in view; alerts as pop-ups and/or notifications, set time ahead +- [024](024-several-tabs.md) Several tabs: a tab takes the state another tab saves (`storage` event); only the tab that last started counting counts play time (`BroadcastChannel`) diff --git a/src/lib/clock.svelte.ts b/src/lib/clock.svelte.ts index a723767..52eee88 100644 --- a/src/lib/clock.svelte.ts +++ b/src/lib/clock.svelte.ts @@ -47,10 +47,23 @@ function onTick() { report(tick(clocks.play, clocks.now)) } +// Only one tab counts play time, or trackers would get it twice (issue #22): a tab that starts +// counting pauses the others. +const tabs = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('lsos:clock') +tabs?.addEventListener('message', () => { + if (clocks.play.paused) return + clocks.now = Date.now() + report(pause(clocks.play, clocks.now)) + saveNow() +}) +if (!clocks.play.paused) tabs?.postMessage('counting') + export function togglePause(): void { clocks.now = Date.now() - if (clocks.play.paused) resume(clocks.play, clocks.now) - else report(pause(clocks.play, clocks.now)) + if (clocks.play.paused) { + resume(clocks.play, clocks.now) + tabs?.postMessage('counting') + } else report(pause(clocks.play, clocks.now)) } export function answerGaps(playing: boolean): void { diff --git a/src/lib/persistence.test.ts b/src/lib/persistence.test.ts index eb4a335..dacfad4 100644 --- a/src/lib/persistence.test.ts +++ b/src/lib/persistence.test.ts @@ -190,6 +190,48 @@ describe('save', () => { }) }) +describe('receive (another tab saved)', () => { + it('takes the other tab state and drops the pending save that would undo it', () => { + const storage = fakeStorage({ [STORAGE_KEY]: current }) + const { persistence, clock } = setup(storage) + persistence.load() + persistence.queue({ ...defaultState(), n: 1 } as never) + + expect(persistence.receive(current)).toEqual(defaultState()) + expect(clock.pending).toBe(0) + persistence.queue(defaultState()) + clock.runAll() + expect(storage.writes).toBe(0) + }) + + it('blocks saving when the other tab saved a newer version', () => { + const raw = JSON.stringify({ version: CURRENT_VERSION + 1 }) + const storage = fakeStorage({ [STORAGE_KEY]: raw }) + const { persistence, clock } = setup(storage) + + expect(persistence.receive(raw)).toBeNull() + expect(persistence.status).toMatchObject({ kind: 'blocked', reason: 'newer', raw }) + persistence.queue(defaultState()) + clock.runAll() + expect(storage.stored).toBe(raw) + }) + + it('unblocks when the other tab imported a usable state', () => { + const storage = fakeStorage({ [STORAGE_KEY]: '{broken' }) + const { persistence } = setup(storage) + persistence.load() + expect(persistence.receive(current)).toEqual(defaultState()) + expect(persistence.status).toEqual({ kind: 'saved' }) + }) + + it('ignores a removed key', () => { + const { persistence, clock } = setup() + persistence.queue(defaultState()) + expect(persistence.receive(null)).toBeNull() + expect(clock.pending).toBe(1) + }) +}) + describe('replace (import)', () => { it('saves at once and drops a pending save', () => { const storage = fakeStorage() diff --git a/src/lib/persistence.ts b/src/lib/persistence.ts index 8c2117b..d6bfe42 100644 --- a/src/lib/persistence.ts +++ b/src/lib/persistence.ts @@ -27,6 +27,11 @@ export interface Persistence { load(): AppState /** Saves `state` after a short delay; a newer call replaces a pending one. */ queue(state: AppState): void + /** + * Takes the text another tab just saved (`storage` event) and drops this tab's pending save, + * which would overwrite it. Returns the new state, or `null` if there is nothing usable. + */ + receive(raw: string | null): AppState | null /** Writes a pending save now (on `pagehide`). */ flush(): void /** Replaces the state with an imported one and saves it now, even over blocked data. */ @@ -67,6 +72,18 @@ export function createPersistence({ storage, schedule, onStatus }: Options): Per } } + /** Parses stored text; unusable text blocks saving so it is never overwritten. */ + function read(raw: string): AppState | null { + const result = parseState(raw) + if (!result.ok) { + setStatus({ kind: 'blocked', reason: result.reason, message: result.message, raw }) + return null + } + // A migrated state differs from what is stored, so it is written on the next save. + lastText = result.fromVersion === result.state.version ? raw : null + return result.state + } + function cancelPending() { cancel?.() cancel = null @@ -88,15 +105,15 @@ export function createPersistence({ storage, schedule, onStatus }: Options): Per return defaultState() } if (raw === null) return defaultState() + return read(raw) ?? defaultState() + }, - const result = parseState(raw) - if (!result.ok) { - setStatus({ kind: 'blocked', reason: result.reason, message: result.message, raw }) - return defaultState() - } - // A migrated state differs from what is stored, so it is written on the next save. - lastText = result.fromVersion === result.state.version ? raw : null - return result.state + receive(raw) { + if (!storage || raw === null) return null + cancelPending() + const state = read(raw) + if (state && status.kind === 'blocked') setStatus({ kind: 'saved' }) + return state }, queue(state) { diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index 6bb8164..197c3f7 100644 --- a/src/lib/store.svelte.ts +++ b/src/lib/store.svelte.ts @@ -1,7 +1,12 @@ // The app's one reactive state, saved to localStorage on every change (decision 006). // Logic lives in persistence.ts, state.ts and the trackers; this file wires it to Svelte and // the browser. -import { createPersistence, type KeyValueStorage, type PersistStatus } from './persistence' +import { + STORAGE_KEY, + createPersistence, + type KeyValueStorage, + type PersistStatus, +} from './persistence' import { clearShutDownStock } from './production' import type { OwnedProperties } from './properties' import { defaultState, parseState, type AppState } from './state' @@ -37,6 +42,12 @@ $effect.root(() => { $effect(() => persistence.queue($state.snapshot(store.state))) }) window.addEventListener('pagehide', () => persistence.flush()) +// Another LSOS tab saved: take its state, so this tab's next save does not undo it (issue #22). +window.addEventListener('storage', (event) => { + if (event.storageArea !== localStorage || event.key !== STORAGE_KEY) return + const state = persistence.receive(event.newValue) + if (state) store.state = state +}) /** * Saves the current state now, for changes made while the page is hiding. The save effect runs