Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/decisions/024-several-tabs.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
17 changes: 15 additions & 2 deletions src/lib/clock.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
42 changes: 42 additions & 0 deletions src/lib/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
33 changes: 25 additions & 8 deletions src/lib/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion src/lib/store.svelte.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down
Loading