diff --git a/CLAUDE.md b/CLAUDE.md index 28c6c82..ef8b0a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ LSOS (Los Santos Operating System) is a static, browser-only GTA Online tracker: safes, cooldowns and weekly events. User data lives in `localStorage`; there is no backend. Svelte 5 + TypeScript + Vite. Public repository. -**Next up: #2** (clocks). Update this line when a PR finishes. +**Next up: #3** (terminal design). Update this line when a PR finishes. ## Where things are @@ -14,6 +14,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | What to work on | the issue named in "Next up", only that issue | | Continue an unfinished PR | `docs/handoff/.md` on the PR branch | | Tabs and hash routing | `src/lib/tabs.ts`, `src/App.svelte` | +| Play/real clocks, `onPlayTime` for trackers | `src/lib/clock.ts`, `clock.svelte.ts` (dec. 008) | | Game logic (pure TS, unit-tested) | `src/lib/` | | Saved user state, migrations, export/import | `src/lib/state.ts`, `src/lib/store.svelte.ts` | | UI components (thin) | `src/components/` | diff --git a/README.md b/README.md index d2aeb8f..d50cdb9 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ is ready again. - **Static site, no account.** Everything you enter stays in your browser (`localStorage`). Nothing is sent anywhere. Export/import lets you back up or move your data. - **Time-based estimates.** LSOS can't see the game. Business production runs while LSOS is - open and not paused ("play time"). Real-world cooldowns such as the Lucky Wheel run on the wall - clock even when LSOS is closed. + open and tracking ("play time"). LSOS opens paused: press Start once you have loaded into a + session, or turn on tracking on open in Settings. Real-world cooldowns such as the Lucky Wheel + run on the wall clock even when LSOS is closed. - **Resync anytime.** When an estimate drifts from what the game shows, click the value and type the real one. - **Weekly events** (bonuses, discounts) come from a data file in this repo that's updated weekly. diff --git a/docs/decisions/003-time-model.md b/docs/decisions/003-time-model.md index 4846e12..c7fbd2c 100644 --- a/docs/decisions/003-time-model.md +++ b/docs/decisions/003-time-model.md @@ -1,6 +1,6 @@ # 003: Two clocks, play time and real time -2026-10-13, project bootstrap +2026-10-13, project bootstrap. How play time is kept between sessions: changed by decision 008. ## Decision @@ -23,7 +23,8 @@ Each tracker declares which clock it uses, and the data for it cites why (decisi - If the gap between two ticks is too large to be throttling (e.g. the computer slept), it is not counted. The exact threshold is set when the clock is built and must exceed browser background throttling (about one minute). -- Closing the tab stops play time. Reopening resumes from the saved total. +- Closing the tab stops play time. (Decision 008: play time is not kept between sessions; + trackers save their own progress.) - A pause toggle covers "LSOS open, but not playing". ## Resync diff --git a/docs/decisions/008-clock-details.md b/docs/decisions/008-clock-details.md new file mode 100644 index 0000000..61e2d54 --- /dev/null +++ b/docs/decisions/008-clock-details.md @@ -0,0 +1,42 @@ +# 008: Clock details: session play time, start paused, throttling and frozen tabs + +2026-10-13, issue #2. Changes one point of decision 003 (play time is no longer kept between +sessions). + +## Decision + +Builds on decision 003. Code: `src/lib/clock.ts` (logic), `src/lib/clock.svelte.ts` (timers, +browser events, listeners), `src/components/ClockBar.svelte` (indicator, toggle, gap prompt). + +- **Play time lasts one session.** It starts at zero each time LSOS opens and is not saved. + Trackers keep their own saved progress, so a running total across sessions adds nothing. +- **Trackers get play time as it is counted**: `onPlayTime(listener)` calls the listener with + each counted stretch (every tick, on pause, on close, and when a long gap is confirmed), and + the tracker adds it to its saved progress. Real-time trackers store a wall-clock time and use + `realElapsed(sinceMs, now)`. +- **LSOS opens paused** unless "Start tracking play time when LSOS opens" is on in Settings + (`clock.trackOnOpen`, saved, off by default). Opening LSOS without playing then counts nothing. + While paused, the clock bar says to press Start once loaded into a GTA Online session, not + during loading screens: per the maintainer, that is close to when the game starts counting. +- **Throttled ticks lose nothing.** Each tick adds the time since the previous one, so a hidden + tab ticking once a minute (Chrome's intensive throttling, typical while a fullscreen game + covers the browser) still counts every second. +- **Gaps up to 5 minutes count; longer gaps are asked about.** Throttled gaps stay under about + two minutes. Longer ones come from the browser freezing the tab (Chrome Energy Saver freezes + tabs hidden for over five minutes) or the computer sleeping. The first happens while the user + plays, the second does not, and the page cannot tell them apart. So long gaps add up and a + prompt asks "Were you playing?" with "Count it" and "Skip". Until answered nothing is counted. + Negative gaps (clock set back) count zero. +- **The page saves when hidden or frozen** (`visibilitychange`, `freeze`), so trackers' progress + survives a tab the browser discards without another event. `saveNow` writes the current state + directly because the save effect would run too late. A discarded tab reloads as a new session. +- **Leaving the page** (`pagehide`) counts up to that moment and stops measuring, so time away + is not counted if the page returns from the back/forward cache. `visibilitychange` fires after + `pagehide` and must not restart the clock. + +## Why + +Play time drives most estimates, and browsers throttle or freeze background tabs exactly when the +user is gaming. Guessing either way is wrong in one of the cases, so LSOS counts what it can prove +and asks about the rest. Starting paused errs the same way: nothing is counted until the user +says they are playing. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 7b18ec9..4da9162 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -13,8 +13,9 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [001](001-static-site.md) Static site, all user data in browser storage, no backend or accounts - [002](002-stack.md) Svelte 5 + TypeScript + Vite, Vitest for tests, Prettier for formatting -- [003](003-time-model.md) Two clocks: play time (only while LSOS is open and not paused) and real time +- [003](003-time-model.md) Two clocks: play time (only while LSOS is open and not paused) and real time (session play time: 008) - [004](004-game-data.md) Every game value lives in `src/data/` with a source; never guessed - [005](005-weekly-data.md) Weekly events come from a JSON file updated by PR, not a live API - [006](006-persisted-state.md) Persisted state is versioned, migrated on load, exportable as JSON - [007](007-state-store.md) State store: unreadable data blocks saving like a newer version; import overrides +- [008](008-clock-details.md) Clock details: session play time, opens paused (setting), throttled ticks counted, long gaps asked about diff --git a/src/App.svelte b/src/App.svelte index 5b8cff3..c0a95d7 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,4 +1,5 @@ + +
+ {paused ? 'PAUSED' : 'TRACKING'} + Session play time {playTime} + Real time {realTime} + + {#if paused} + + Press Start once you have loaded into a GTA Online session, not during loading screens: that + is when the game starts counting. + + {/if} +
+ +{#if unconfirmed > 0} +
+ + LSOS could not track {formatDuration(unconfirmed)}: the browser paused this + tab or the computer slept. Were you playing? + + + +
+{/if} + + diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index 99169f1..68a62b8 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -33,6 +33,19 @@ } +
+

Clock

+ +

+ Off (recommended): LSOS opens paused. Press Start once you have loaded into a GTA Online + session, since that is when the game starts counting. On: loading screens count as play time + too, unless you open LSOS only once you are in. +

+
+

Backup

@@ -75,6 +88,21 @@ text-transform: uppercase; } + label { + align-items: center; + cursor: pointer; + display: flex; + gap: 0.5rem; + } + + input[type='checkbox'] { + accent-color: var(--accent); + } + + .hint { + color: var(--muted); + } + .error { color: var(--warn); } diff --git a/src/lib/clock.svelte.ts b/src/lib/clock.svelte.ts new file mode 100644 index 0000000..a723767 --- /dev/null +++ b/src/lib/clock.svelte.ts @@ -0,0 +1,84 @@ +// Runs the session's play clock and offers both clocks to the UI (decision 003). +// Logic lives in clock.ts; this file only wires it to Svelte, timers and the browser. +import { + TICK_INTERVAL_MS, + close, + confirmGaps, + dismissGaps, + pause, + resume, + startSession, + tick, + type ClockState, +} from './clock' +import { saveNow, store } from './store.svelte' + +class Clocks { + /** Wall-clock time of the latest tick, epoch ms. Read it to re-render once per tick. */ + now = $state(Date.now()) + /** This session's play clock. */ + play: ClockState = $state(startSession(store.state.clock, Date.now())) +} + +export const clocks = new Clocks() + +type PlayListener = (ms: number) => void +const listeners = new Set() + +/** + * Calls `listener` with each stretch of play time as it is counted, so a tracker can add it to + * its own saved progress. Returns a function that stops the calls. + */ +export function onPlayTime(listener: PlayListener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function report(ms: number) { + if (ms > 0) for (const listener of listeners) listener(ms) +} + +/** Set on `pagehide`. Browsers fire `visibilitychange` after it, which must not restart the clock. */ +let closed = false + +function onTick() { + if (closed) return + clocks.now = Date.now() + report(tick(clocks.play, clocks.now)) +} + +export function togglePause(): void { + clocks.now = Date.now() + if (clocks.play.paused) resume(clocks.play, clocks.now) + else report(pause(clocks.play, clocks.now)) +} + +export function answerGaps(playing: boolean): void { + if (playing) report(confirmGaps(clocks.play)) + else dismissGaps(clocks.play) +} + +setInterval(onTick, TICK_INTERVAL_MS) +// A hidden tab may not have ticked for up to a minute. Count that time whenever the page is +// hidden, frozen (Chrome Energy Saver) or shown again, and save trackers' progress before the +// browser can discard the tab. +document.addEventListener('visibilitychange', () => { + onTick() + saveNow() +}) +document.addEventListener('freeze', () => { + onTick() + saveNow() +}) +document.addEventListener('resume', onTick) +window.addEventListener('pagehide', () => { + report(close(clocks.play, Date.now())) + closed = true + saveNow() +}) +// Back from the back/forward cache: the time away was not spent in LSOS, so start measuring anew. +window.addEventListener('pageshow', (event) => { + if (!event.persisted) return + closed = false + onTick() +}) diff --git a/src/lib/clock.test.ts b/src/lib/clock.test.ts new file mode 100644 index 0000000..2cb7ac5 --- /dev/null +++ b/src/lib/clock.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_TICK_GAP_MS, + close, + confirmGaps, + dismissGaps, + formatDuration, + pause, + realElapsed, + resume, + startSession, + tick, + type ClockState, +} from './clock' + +const START = Date.UTC(2026, 9, 13, 20, 0) +const SECOND = 1000 +const MINUTE = 60 * SECOND + +/** A session that is tracking from `START`. */ +function setup(clock: ClockState = startSession({ trackOnOpen: true }, START)) { + let now = START + let reported = 0 + return { + clock, + /** Moves the fake clock forward by each step and ticks after each one. */ + tickAfter(...steps: number[]) { + for (const step of steps) { + now += step + reported += tick(clock, now) + } + }, + advance(ms: number) { + now += ms + }, + now: () => now, + /** Play time handed to trackers so far. */ + reported: () => reported, + } +} + +describe('startSession', () => { + it('starts paused at zero by default', () => { + const clock = startSession({ trackOnOpen: false }, START) + expect(clock).toEqual({ playMs: 0, paused: true, lastTickMs: null, unconfirmedMs: 0 }) + tick(clock, START + MINUTE) + expect(clock.playMs).toBe(0) + }) + + it('starts tracking from now when set to track on open', () => { + const clock = startSession({ trackOnOpen: true }, START) + expect(tick(clock, START + SECOND)).toBe(SECOND) + }) +}) + +describe('tick', () => { + it('adds the time between ticks in a normal run and reports it', () => { + const t = setup() + t.tickAfter(...Array(90).fill(SECOND)) + expect(t.clock.playMs).toBe(90 * SECOND) + expect(t.reported()).toBe(90 * SECOND) + }) + + it('counts elapsed time, not ticks, in a throttled background tab', () => { + const t = setup() + t.tickAfter(SECOND, 59 * SECOND, MINUTE, 118 * SECOND, 1500) + expect(t.clock.playMs).toBe(SECOND + 59 * SECOND + MINUTE + 118 * SECOND + 1500) + expect(t.clock.unconfirmedMs).toBe(0) + }) + + it('counts a gap of exactly the threshold', () => { + const t = setup() + t.tickAfter(MAX_TICK_GAP_MS) + expect(t.clock.playMs).toBe(MAX_TICK_GAP_MS) + }) + + it('holds a larger gap (frozen tab or sleep) for the user to confirm, then continues', () => { + const t = setup() + t.tickAfter(10 * SECOND, 40 * MINUTE, SECOND, 2 * 60 * MINUTE, SECOND) + expect(t.clock).toMatchObject({ playMs: 12 * SECOND, unconfirmedMs: 160 * MINUTE }) + expect(t.reported()).toBe(12 * SECOND) + }) + + it('ignores a clock set back', () => { + const t = setup() + t.tickAfter(10 * SECOND, -MINUTE, SECOND) + expect(t.clock).toMatchObject({ playMs: 11 * SECOND, unconfirmedMs: 0 }) + }) +}) + +describe('confirming long gaps', () => { + it('adds them to play time and reports them when the user was playing', () => { + const t = setup() + t.tickAfter(MINUTE, 30 * MINUTE) + expect(confirmGaps(t.clock)).toBe(30 * MINUTE) + expect(t.clock).toMatchObject({ playMs: 31 * MINUTE, unconfirmedMs: 0 }) + }) + + it('drops them when the user was not playing', () => { + const t = setup() + t.tickAfter(MINUTE, 30 * MINUTE) + dismissGaps(t.clock) + expect(t.clock).toMatchObject({ playMs: MINUTE, unconfirmedMs: 0 }) + }) +}) + +describe('pause and resume', () => { + it('does not count paused time, and counts up to the pause', () => { + const t = setup() + t.tickAfter(SECOND) + t.advance(400) + expect(pause(t.clock, t.now())).toBe(400) + expect(t.clock).toMatchObject({ playMs: 1400, paused: true }) + + t.tickAfter(SECOND, 30 * MINUTE) + expect(t.clock).toMatchObject({ playMs: 1400, unconfirmedMs: 0 }) + + t.advance(SECOND) + resume(t.clock, t.now()) + t.tickAfter(SECOND, SECOND) + expect(t.clock.playMs).toBe(3400) + }) +}) + +describe('leaving the page', () => { + it('counts up to the close and not the time away', () => { + const t = setup() + t.tickAfter(MINUTE) + t.advance(500) + expect(close(t.clock, t.now())).toBe(500) + + // Back from the back/forward cache two minutes later. + t.tickAfter(2 * MINUTE, SECOND) + expect(t.clock.playMs).toBe(MINUTE + 500 + SECOND) + }) + + it('starts the next session at zero, paused by default', () => { + const t = setup() + t.tickAfter(MINUTE) + close(t.clock, t.now()) + expect(startSession({ trackOnOpen: false }, t.now() + MINUTE)).toMatchObject({ + playMs: 0, + paused: true, + }) + }) +}) + +describe('realElapsed', () => { + it('is the wall-clock time since a moment, never negative', () => { + expect(realElapsed(START, START + MINUTE)).toBe(MINUTE) + expect(realElapsed(START, START - MINUTE)).toBe(0) + }) +}) + +describe('formatDuration', () => { + it.each([ + [0, '0:00:00'], + [999, '0:00:00'], + [61 * SECOND, '0:01:01'], + [(12 * 3600 + 4 * 60 + 9) * SECOND, '12:04:09'], + [123 * 3600 * SECOND, '123:00:00'], + [-5, '0:00:00'], + ])('formats %d ms as %s', (ms, text) => { + expect(formatDuration(ms)).toBe(text) + }) +}) diff --git a/src/lib/clock.ts b/src/lib/clock.ts new file mode 100644 index 0000000..5ec57a7 --- /dev/null +++ b/src/lib/clock.ts @@ -0,0 +1,113 @@ +// Play time and real time (decision 003). Pure logic: every function takes `now` (epoch ms). + +/** The play clock of the current session. Kept in memory only; each session starts at zero. */ +export interface ClockState { + /** Play time counted this session, in ms. */ + playMs: number + /** While paused, play time does not advance. */ + paused: boolean + /** Wall-clock time of the last tick while running, epoch ms. `null` while paused or closed. */ + lastTickMs: number | null + /** Long gaps waiting for the user to say whether they were playing (decision 008). */ + unconfirmedMs: number +} + +/** The saved clock setting. */ +export interface ClockSettings { + /** Start tracking as soon as LSOS opens, instead of paused. */ + trackOnOpen: boolean +} + +export function defaultClockSettings(): ClockSettings { + return { trackOnOpen: false } +} + +/** A new session's clock: paused unless the user chose to track on open. */ +export function startSession(settings: ClockSettings, now: number): ClockState { + return { + playMs: 0, + paused: !settings.trackOnOpen, + lastTickMs: settings.trackOnOpen ? now : null, + unconfirmedMs: 0, + } +} + +/** + * Gaps between ticks up to this long are counted as play time; longer ones are asked about. + * Chrome runs timers in hidden tabs only once a minute, aligned to the minute, so throttled gaps + * can approach two minutes. Five minutes leaves room for that and for a busy machine. + * Longer gaps come from a frozen tab (still playing) or a sleeping computer (not playing), + * which LSOS cannot tell apart, so the user decides. + */ +export const MAX_TICK_GAP_MS = 5 * 60_000 + +/** How often the app ticks while open. Any interval works; it only sets display smoothness. */ +export const TICK_INTERVAL_MS = 1000 + +/** + * Advances play time from timestamps, never from tick counts, so throttled ticks lose nothing. + * Returns the play time counted by this tick, for trackers to add to their progress. + */ +export function tick(clock: ClockState, now: number): number { + if (clock.paused) return 0 + let counted = 0 + if (clock.lastTickMs !== null) { + const gap = now - clock.lastTickMs + // A negative gap means the system clock was set back; nothing is counted. + if (gap > MAX_TICK_GAP_MS) clock.unconfirmedMs += gap + else if (gap > 0) counted = gap + } + clock.playMs += counted + clock.lastTickMs = now + return counted +} + +/** Counts the time up to `now`, then stops play time. Returns the play time counted. */ +export function pause(clock: ClockState, now: number): number { + const counted = tick(clock, now) + clock.paused = true + clock.lastTickMs = null + return counted +} + +/** Starts play time again from `now`; the paused stretch is not counted. */ +export function resume(clock: ClockState, now: number): void { + clock.paused = false + clock.lastTickMs = now +} + +/** + * The page is being left (`pagehide`): counts up to `now`, then forgets the last tick, so time + * away is not counted if the page comes back from the back/forward cache. + */ +export function close(clock: ClockState, now: number): number { + const counted = tick(clock, now) + clock.lastTickMs = null + return counted +} + +/** The user was playing during the long gaps: count them. Returns the play time counted. */ +export function confirmGaps(clock: ClockState): number { + const counted = clock.unconfirmedMs + clock.playMs += counted + clock.unconfirmedMs = 0 + return counted +} + +/** The user was not playing during the long gaps: drop them. */ +export function dismissGaps(clock: ClockState): void { + clock.unconfirmedMs = 0 +} + +/** Real time since `sinceMs` (epoch ms). Never negative, even if the clock was set back. */ +export function realElapsed(sinceMs: number, now: number): number { + return Math.max(0, now - sinceMs) +} + +/** A duration as `h:mm:ss`, e.g. `12:04:09`; hours grow past 99 as needed. */ +export function formatDuration(ms: number): string { + const total = Math.floor(Math.max(0, ms) / 1000) + const pad = (n: number) => String(n).padStart(2, '0') + const hours = Math.floor(total / 3600) + return `${hours}:${pad(Math.floor(total / 60) % 60)}:${pad(total % 60)}` +} diff --git a/src/lib/persistence.test.ts b/src/lib/persistence.test.ts index eb237a5..eb4a335 100644 --- a/src/lib/persistence.test.ts +++ b/src/lib/persistence.test.ts @@ -133,7 +133,7 @@ describe('save', () => { clock.runAll() expect(storage.writes).toBe(1) - expect(JSON.parse(storage.stored!)).toEqual({ version: CURRENT_VERSION, n: 2 }) + expect(JSON.parse(storage.stored!)).toEqual({ ...defaultState(), n: 2 }) }) it('writes a pending save on flush and cancels the timer', () => { diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts index a13e6bc..8e0eef2 100644 --- a/src/lib/state.test.ts +++ b/src/lib/state.test.ts @@ -16,10 +16,36 @@ describe('parseState', () => { }) it('drops unknown fields', () => { - const result = parseState(JSON.stringify({ version: CURRENT_VERSION, junk: 1 })) + const result = parseState(JSON.stringify({ ...defaultState(), junk: 1 })) expect(result.ok && result.state).toEqual(defaultState()) }) + it('migrates a version 1 sample, starting paused by default', () => { + const result = parseState('{"version":1}') + expect(result).toEqual({ + ok: true, + state: { + version: 2, + clock: { trackOnOpen: false }, + }, + fromVersion: 1, + }) + }) + + it('keeps the saved clock setting', () => { + const text = '{"version":2,"clock":{"trackOnOpen":true,"playMs":5}}' + expect(parseState(text)).toMatchObject({ ok: true, state: { clock: { trackOnOpen: true } } }) + }) + + it.each([ + ['a missing clock', '{"version":2}', 'clock section is missing'], + ['a bad tracking setting', '{"version":2,"clock":{"trackOnOpen":"yes"}}', 'tracking setting'], + ])('rejects %s', (_, text, message) => { + const result = parseState(text) + expect(result).toMatchObject({ ok: false, reason: 'invalid' }) + expect(!result.ok && result.message).toContain(message) + }) + it('reports a newer version without touching it', () => { const result = parseState(JSON.stringify({ version: CURRENT_VERSION + 1 })) expect(result).toMatchObject({ ok: false, reason: 'newer', version: CURRENT_VERSION + 1 }) diff --git a/src/lib/state.ts b/src/lib/state.ts index b96453e..5f25bf4 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -1,15 +1,18 @@ // The persisted root state, its versions and migrations (decision 006). +import { defaultClockSettings, type ClockSettings } from './clock' /** Bump when the stored shape changes, and add a migration from the previous version. */ -export const CURRENT_VERSION = 1 +export const CURRENT_VERSION = 2 /** All user state. Trackers add their own sections here as they are built. */ export interface AppState { version: typeof CURRENT_VERSION + /** Clock settings (decision 008). Play time itself lasts one session and is not saved. */ + clock: ClockSettings } export function defaultState(): AppState { - return { version: CURRENT_VERSION } + return { version: CURRENT_VERSION, clock: defaultClockSettings() } } export type StoredData = Record & { version: number } @@ -18,7 +21,10 @@ export type StoredData = Record & { version: number } export type Migration = (data: StoredData) => Record /** `MIGRATIONS[n]` upgrades version `n` to `n + 1`. Each one has a test with a stored sample. */ -export const MIGRATIONS: Readonly> = {} +export const MIGRATIONS: Readonly> = { + // Version 2 adds the clock (issue #2). + 1: (data) => ({ ...data, clock: defaultClockSettings() }), +} export type ParseResult = | { ok: true; state: AppState; fromVersion: number } @@ -86,7 +92,16 @@ export function exportFileName(now: Date): string { /** Checks current-version data and keeps only known fields. Returns an error message if bad. */ function toState(data: StoredData): AppState | string { if (data.version !== CURRENT_VERSION) return `Unexpected data version ${data.version}.` - return { version: CURRENT_VERSION } + const clock = toClock(data.clock) + if (typeof clock === 'string') return clock + return { version: CURRENT_VERSION, clock } +} + +function toClock(data: unknown): ClockSettings | string { + if (!isRecord(data)) return 'The clock section is missing.' + const { trackOnOpen } = data + if (typeof trackOnOpen !== 'boolean') return 'The saved tracking setting is not true or false.' + return { trackOnOpen } } function isRecord(value: unknown): value is Record { diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index b1a4fe4..0c2bab4 100644 --- a/src/lib/store.svelte.ts +++ b/src/lib/store.svelte.ts @@ -35,6 +35,15 @@ $effect.root(() => { }) window.addEventListener('pagehide', () => persistence.flush()) +/** + * Saves the current state now, for changes made while the page is hiding. The save effect runs + * only after the current task, which may never come for a page being closed or frozen. + */ +export function saveNow(): void { + persistence.queue($state.snapshot(store.state)) + persistence.flush() +} + /** JSON text for a backup file. While stored data is blocked, that data is exported instead. */ export function exportText(): string { return persistence.exportText($state.snapshot(store.state))