diff --git a/CLAUDE.md b/CLAUDE.md index bcdb26c..28c6c82 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: #1** (persisted state store), then #2 (clocks). Update this line when a PR finishes. +**Next up: #2** (clocks). Update this line when a PR finishes. ## Where things are @@ -15,6 +15,8 @@ Svelte 5 + TypeScript + Vite. Public repository. | Continue an unfinished PR | `docs/handoff/.md` on the PR branch | | Tabs and hash routing | `src/lib/tabs.ts`, `src/App.svelte` | | 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/` | | Game data with sources | `src/data/` (decision 004) | | Theme tokens | `src/app.css` | | CI, templates | `.github/` | diff --git a/docs/decisions/007-state-store.md b/docs/decisions/007-state-store.md new file mode 100644 index 0000000..41c868e --- /dev/null +++ b/docs/decisions/007-state-store.md @@ -0,0 +1,29 @@ +# 007: How the state store handles bad, blocked and unsaved data + +2026-10-13, issue #1 + +## Decision + +Builds on decision 006. Code: `src/lib/state.ts` (versions, migrations, parsing), +`src/lib/persistence.ts` (storage, debounce), `src/lib/store.svelte.ts` (Svelte wiring). + +- **Unreadable data is treated like a newer version.** Invalid JSON or a bad shape under the + storage key blocks saving, shows a warning and is offered for export as it is. It is never + replaced by a fresh state on its own. +- **Import is the only way past a block.** Importing a file replaces everything, overwrites + blocked data and saves at once. The Settings tab asks for confirmation first, with a stronger + warning while data is blocked. +- **Export while blocked exports the stored text**, not the empty in-memory state, because that + text is the data worth keeping. +- **Storage full or unavailable:** the app keeps running in memory and shows a warning. When + storage exists, every save still tries again, so the warning clears once space is freed. +- **Only known fields are kept** when parsing the current version; unknown keys are dropped. + Each tracker validates its own section when it adds one. +- **Saves are skipped when the text equals what was last read or written**, so loading the app + does not rewrite storage. +- The debounce timer is injected (`Schedule`), so tests drive time by hand (decision 003). + +## Why + +`localStorage` is the only copy (decision 001). Any automatic path that writes over data the app +cannot read would lose it for good; requiring an explicit import keeps that a user decision. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 70ee2b5..7b18ec9 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -17,3 +17,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [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 diff --git a/src/App.svelte b/src/App.svelte index 49e06b8..5b8cff3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,4 +1,6 @@ + +{#if status.kind === 'memory'} + +{:else if status.kind === 'blocked'} + +{/if} + + diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte new file mode 100644 index 0000000..99169f1 --- /dev/null +++ b/src/components/Settings.svelte @@ -0,0 +1,81 @@ + + +
+

Backup

+

+ Your data lives only in this browser. Export it to keep a copy or to move to another device. +

+
+ + + +
+ {#if message} +

{message.text}

+ {/if} +
+ + diff --git a/src/lib/persistence.test.ts b/src/lib/persistence.test.ts new file mode 100644 index 0000000..eb237a5 --- /dev/null +++ b/src/lib/persistence.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' +import { + SAVE_DELAY_MS, + STORAGE_KEY, + createPersistence, + type KeyValueStorage, + type PersistStatus, +} from './persistence' +import { CURRENT_VERSION, defaultState, serializeState } from './state' + +/** In-memory storage that can be told to fail. */ +function fakeStorage(initial: Record = {}) { + const data = new Map(Object.entries(initial)) + const storage = { + writes: 0, + failWith: null as unknown, + failReads: false, + getItem(key: string) { + if (storage.failReads) throw new Error('SecurityError') + return data.get(key) ?? null + }, + setItem(key: string, value: string) { + if (storage.failWith) throw storage.failWith + storage.writes++ + data.set(key, value) + }, + get stored() { + return data.get(STORAGE_KEY) + }, + } + return storage satisfies KeyValueStorage +} + +/** Collects scheduled callbacks so a test decides when time passes. */ +function fakeSchedule() { + let tasks: { fn: () => void; ms: number }[] = [] + return { + schedule(fn: () => void, ms: number) { + const task = { fn, ms } + tasks.push(task) + return () => (tasks = tasks.filter((t) => t !== task)) + }, + get pending() { + return tasks.length + }, + runAll() { + const due = tasks + tasks = [] + for (const task of due) { + expect(task.ms).toBe(SAVE_DELAY_MS) + task.fn() + } + }, + } +} + +function setup(storage: KeyValueStorage | null = fakeStorage()) { + const clock = fakeSchedule() + const statuses: PersistStatus[] = [] + const persistence = createPersistence({ + storage, + schedule: clock.schedule, + onStatus: (s) => statuses.push(s), + }) + return { persistence, clock, statuses } +} + +const current = serializeState(defaultState()) + +describe('load', () => { + it('starts fresh when nothing is stored', () => { + const { persistence } = setup() + expect(persistence.load()).toEqual(defaultState()) + expect(persistence.status).toEqual({ kind: 'saved' }) + }) + + it('reads the stored state', () => { + const { persistence } = setup(fakeStorage({ [STORAGE_KEY]: current })) + expect(persistence.load()).toEqual(defaultState()) + }) + + it('blocks saving over a newer version and offers it for export', () => { + const raw = JSON.stringify({ version: CURRENT_VERSION + 1, future: true }) + const storage = fakeStorage({ [STORAGE_KEY]: raw }) + const { persistence, clock } = setup(storage) + + expect(persistence.load()).toEqual(defaultState()) + expect(persistence.status).toMatchObject({ kind: 'blocked', reason: 'newer', raw }) + + persistence.queue(defaultState()) + clock.runAll() + persistence.flush() + expect(storage.stored).toBe(raw) + expect(persistence.exportText(defaultState())).toBe(raw) + }) + + it('blocks saving over unreadable data', () => { + const storage = fakeStorage({ [STORAGE_KEY]: '{broken' }) + const { persistence, clock } = setup(storage) + persistence.load() + expect(persistence.status).toMatchObject({ kind: 'blocked', reason: 'invalid' }) + persistence.queue(defaultState()) + clock.runAll() + expect(storage.stored).toBe('{broken') + }) + + it('works in memory when storage cannot be read', () => { + const storage = fakeStorage() + storage.failReads = true + const { persistence, statuses } = setup(storage) + expect(persistence.load()).toEqual(defaultState()) + expect(statuses).toEqual([{ kind: 'memory', reason: 'unavailable' }]) + }) + + it('works in memory without any storage', () => { + const { persistence, clock } = setup(null) + expect(persistence.load()).toEqual(defaultState()) + expect(persistence.status).toEqual({ kind: 'memory', reason: 'unavailable' }) + persistence.queue(defaultState()) + clock.runAll() + }) +}) + +describe('save', () => { + it('debounces writes and keeps the latest state', () => { + const storage = fakeStorage() + const { persistence, clock } = setup(storage) + persistence.load() + persistence.queue({ ...defaultState(), n: 1 } as never) + persistence.queue({ ...defaultState(), n: 2 } as never) + expect(clock.pending).toBe(1) + expect(storage.writes).toBe(0) + + clock.runAll() + expect(storage.writes).toBe(1) + expect(JSON.parse(storage.stored!)).toEqual({ version: CURRENT_VERSION, n: 2 }) + }) + + it('writes a pending save on flush and cancels the timer', () => { + const storage = fakeStorage() + const { persistence, clock } = setup(storage) + persistence.load() + persistence.queue(defaultState()) + persistence.flush() + expect(storage.stored).toBe(current) + expect(clock.pending).toBe(0) + }) + + it('does nothing on flush without a pending save', () => { + const storage = fakeStorage() + const { persistence } = setup(storage) + persistence.load() + persistence.flush() + expect(storage.writes).toBe(0) + }) + + it('skips writing what is already stored', () => { + const storage = fakeStorage({ [STORAGE_KEY]: current }) + const { persistence, clock } = setup(storage) + persistence.load() + persistence.queue(defaultState()) + clock.runAll() + expect(storage.writes).toBe(0) + }) + + it('keeps working in memory when storage is full, and recovers', () => { + const storage = fakeStorage() + const { persistence, clock, statuses } = setup(storage) + persistence.load() + + storage.failWith = Object.assign(new Error('full'), { name: 'QuotaExceededError' }) + persistence.queue(defaultState()) + clock.runAll() + expect(persistence.status).toEqual({ kind: 'memory', reason: 'full' }) + + storage.failWith = null + persistence.queue(defaultState()) + clock.runAll() + expect(storage.stored).toBe(current) + expect(statuses).toEqual([{ kind: 'memory', reason: 'full' }, { kind: 'saved' }]) + }) + + it('reports other write errors as unavailable', () => { + const storage = fakeStorage() + storage.failWith = new Error('SecurityError') + const { persistence } = setup(storage) + persistence.load() + persistence.replace(defaultState()) + expect(persistence.status).toEqual({ kind: 'memory', reason: 'unavailable' }) + }) +}) + +describe('replace (import)', () => { + it('saves at once and drops a pending save', () => { + const storage = fakeStorage() + const { persistence, clock } = setup(storage) + persistence.load() + persistence.queue({ ...defaultState(), n: 1 } as never) + persistence.replace(defaultState()) + expect(storage.stored).toBe(current) + expect(clock.pending).toBe(0) + }) + + it('overwrites blocked data when the user imports', () => { + const storage = fakeStorage({ [STORAGE_KEY]: JSON.stringify({ version: 999 }) }) + const { persistence } = setup(storage) + persistence.load() + persistence.replace(defaultState()) + expect(storage.stored).toBe(current) + expect(persistence.status).toEqual({ kind: 'saved' }) + expect(persistence.exportText(defaultState())).toBe(current) + }) +}) diff --git a/src/lib/persistence.ts b/src/lib/persistence.ts new file mode 100644 index 0000000..8c2117b --- /dev/null +++ b/src/lib/persistence.ts @@ -0,0 +1,141 @@ +// Saving and loading the root state in browser storage (decision 006). +import { defaultState, parseState, serializeState, type AppState } from './state' + +export const STORAGE_KEY = 'lsos:state' +export const SAVE_DELAY_MS = 500 + +/** The part of `localStorage` used here, so tests can pass a plain object. */ +export interface KeyValueStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +/** Runs `fn` after `ms` and returns a function that cancels it (`setTimeout` in the app). */ +export type Schedule = (fn: () => void, ms: number) => () => void + +export type PersistStatus = + /** Saving works. */ + | { kind: 'saved' } + /** Changes stay in memory only and are lost when the tab closes. */ + | { kind: 'memory'; reason: 'unavailable' | 'full' } + /** Stored data could not be read; it is never overwritten. `raw` is offered for export. */ + | { kind: 'blocked'; reason: 'newer' | 'invalid'; message: string; raw: string } + +export interface Persistence { + readonly status: PersistStatus + /** Reads the stored state. Falls back to a fresh state if there is none or it is unusable. */ + load(): AppState + /** Saves `state` after a short delay; a newer call replaces a pending one. */ + queue(state: AppState): void + /** Writes a pending save now (on `pagehide`). */ + flush(): void + /** Replaces the state with an imported one and saves it now, even over blocked data. */ + replace(state: AppState): void + /** Text for an export file: the blocked stored data if there is any, else `state`. */ + exportText(state: AppState): string +} + +interface Options { + /** `null` when browser storage cannot be used at all. */ + storage: KeyValueStorage | null + schedule: Schedule + onStatus?: (status: PersistStatus) => void +} + +export function createPersistence({ storage, schedule, onStatus }: Options): Persistence { + let status: PersistStatus = storage + ? { kind: 'saved' } + : { kind: 'memory', reason: 'unavailable' } + /** Last text written or read, to skip writes that change nothing. */ + let lastText: string | null = null + let pending: string | null = null + let cancel: (() => void) | null = null + + function setStatus(next: PersistStatus) { + status = next + onStatus?.(next) + } + + function write(text: string) { + if (!storage || status.kind === 'blocked' || text === lastText) return + try { + storage.setItem(STORAGE_KEY, text) + lastText = text + if (status.kind !== 'saved') setStatus({ kind: 'saved' }) + } catch (error) { + setStatus({ kind: 'memory', reason: isQuotaError(error) ? 'full' : 'unavailable' }) + } + } + + function cancelPending() { + cancel?.() + cancel = null + pending = null + } + + return { + get status() { + return status + }, + + load() { + if (!storage) return defaultState() + let raw: string | null + try { + raw = storage.getItem(STORAGE_KEY) + } catch { + setStatus({ kind: 'memory', reason: 'unavailable' }) + return defaultState() + } + if (raw === null) return 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 + }, + + queue(state) { + cancelPending() + pending = serializeState(state) + cancel = schedule(() => { + const text = pending + cancel = null + pending = null + if (text !== null) write(text) + }, SAVE_DELAY_MS) + }, + + flush() { + const text = pending + cancelPending() + if (text !== null) write(text) + }, + + replace(state) { + cancelPending() + if (status.kind === 'blocked') setStatus({ kind: 'saved' }) + write(serializeState(state)) + }, + + exportText(state) { + return status.kind === 'blocked' ? status.raw : serializeState(state) + }, + } +} + +/** Browsers name this differently; Firefox used its own name and code. */ +function isQuotaError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const { name, code } = error as { name?: unknown; code?: unknown } + return ( + name === 'QuotaExceededError' || + name === 'NS_ERROR_DOM_QUOTA_REACHED' || + code === 22 || + code === 1014 + ) +} diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts new file mode 100644 index 0000000..a13e6bc --- /dev/null +++ b/src/lib/state.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + CURRENT_VERSION, + defaultState, + exportFileName, + migrate, + parseState, + serializeState, + type Migration, +} from './state' + +describe('parseState', () => { + it('round-trips the default state', () => { + const result = parseState(serializeState(defaultState())) + expect(result).toEqual({ ok: true, state: defaultState(), fromVersion: CURRENT_VERSION }) + }) + + it('drops unknown fields', () => { + const result = parseState(JSON.stringify({ version: CURRENT_VERSION, junk: 1 })) + expect(result.ok && result.state).toEqual(defaultState()) + }) + + 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 }) + }) + + it.each([ + ['not JSON', '{oops', 'not valid JSON'], + ['an array', '[1]', 'expected a JSON object'], + ['null', 'null', 'expected a JSON object'], + ['no version', '{}', 'no valid version'], + ['a string version', '{"version":"1"}', 'no valid version'], + ['a fractional version', '{"version":1.5}', 'no valid version'], + ['version zero', '{"version":0}', 'no valid version'], + ])('rejects %s', (_, text, message) => { + const result = parseState(text) + expect(result.ok).toBe(false) + expect(!result.ok && result.reason).toBe('invalid') + expect(!result.ok && result.message).toContain(message) + }) +}) + +describe('migrate', () => { + const migrations: Record = { + 1: (data) => ({ ...data, renamed: data.old }), + 2: ({ renamed, ...rest }) => ({ ...rest, list: [renamed] }), + } + + it('runs each step in order and sets the version', () => { + expect(migrate({ version: 1, old: 'x' }, 3, migrations)).toEqual({ + version: 3, + old: 'x', + list: ['x'], + }) + }) + + it('starts from the stored version', () => { + expect(migrate({ version: 2, renamed: 'y' }, 3, migrations)).toEqual({ + version: 3, + list: ['y'], + }) + }) + + it('leaves current data alone', () => { + const data = { version: 3, list: [] } + expect(migrate(data, 3, migrations)).toBe(data) + }) + + it('does not mutate its input', () => { + const data = { version: 1, old: 'x' } + migrate(data, 3, migrations) + expect(data).toEqual({ version: 1, old: 'x' }) + }) + + it('reports a missing step', () => { + expect(migrate({ version: 1 }, 4, migrations)).toBe('No migration from data version 3.') + }) +}) + +describe('exportFileName', () => { + it('uses the local date', () => { + expect(exportFileName(new Date(2026, 0, 5, 23, 59))).toBe('lsos-backup-2026-01-05.json') + }) +}) diff --git a/src/lib/state.ts b/src/lib/state.ts new file mode 100644 index 0000000..b96453e --- /dev/null +++ b/src/lib/state.ts @@ -0,0 +1,98 @@ +// The persisted root state, its versions and migrations (decision 006). + +/** Bump when the stored shape changes, and add a migration from the previous version. */ +export const CURRENT_VERSION = 1 + +/** All user state. Trackers add their own sections here as they are built. */ +export interface AppState { + version: typeof CURRENT_VERSION +} + +export function defaultState(): AppState { + return { version: CURRENT_VERSION } +} + +export type StoredData = Record & { version: number } + +/** Upgrades data of one version to the next. Pure: returns new data, never mutates its input. */ +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 type ParseResult = + | { ok: true; state: AppState; fromVersion: number } + | { ok: false; reason: 'newer'; version: number; message: string } + | { ok: false; reason: 'invalid'; message: string } + +/** Parses saved or imported JSON text, migrating older versions to the current one. */ +export function parseState(text: string): ParseResult { + let data: unknown + try { + data = JSON.parse(text) + } catch { + return invalid('This is not valid JSON.') + } + if (!isRecord(data)) return invalid('This is not LSOS data: expected a JSON object.') + const { version } = data + if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) { + return invalid('This is not LSOS data: it has no valid version number.') + } + if (version > CURRENT_VERSION) { + return { + ok: false, + reason: 'newer', + version, + message: `This data is from a newer LSOS (data version ${version}; this LSOS knows up to ${CURRENT_VERSION}).`, + } + } + + const migrated = migrate({ ...data, version }, CURRENT_VERSION) + if (typeof migrated === 'string') return invalid(migrated) + const state = toState(migrated) + if (typeof state === 'string') return invalid(state) + return { ok: true, state, fromVersion: version } +} + +/** + * Runs migrations one version at a time until `data` reaches `target`. + * Returns an error message if a step is missing. + */ +export function migrate( + data: StoredData, + target: number, + migrations: Readonly> = MIGRATIONS, +): StoredData | string { + let current = data + for (let v = data.version; v < target; v++) { + const migration = migrations[v] + if (!migration) return `No migration from data version ${v}.` + current = { ...migration(current), version: v + 1 } + } + return current +} + +export function serializeState(state: AppState): string { + return JSON.stringify(state) +} + +/** Name for an exported backup, e.g. `lsos-backup-2026-10-13.json` (local date). */ +export function exportFileName(now: Date): string { + const pad = (n: number) => String(n).padStart(2, '0') + const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + return `lsos-backup-${date}.json` +} + +/** 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 } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalid(message: string): ParseResult { + return { ok: false, reason: 'invalid', message } +} diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts new file mode 100644 index 0000000..b1a4fe4 --- /dev/null +++ b/src/lib/store.svelte.ts @@ -0,0 +1,50 @@ +// The app's one reactive state, saved to localStorage on every change (decision 006). +// Logic lives in persistence.ts and state.ts; this file only wires them to Svelte and the browser. +import { createPersistence, type KeyValueStorage, type PersistStatus } from './persistence' +import { defaultState, parseState, type AppState } from './state' + +function browserStorage(): KeyValueStorage | null { + try { + return window.localStorage + } catch { + return null + } +} + +class Store { + state: AppState = $state(defaultState()) + status: PersistStatus = $state({ kind: 'saved' }) +} + +export const store = new Store() + +const persistence = createPersistence({ + storage: browserStorage(), + schedule: (fn, ms) => { + const id = setTimeout(fn, ms) + return () => clearTimeout(id) + }, + onStatus: (status) => (store.status = status), +}) + +store.state = persistence.load() +store.status = persistence.status + +$effect.root(() => { + $effect(() => persistence.queue($state.snapshot(store.state))) +}) +window.addEventListener('pagehide', () => 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)) +} + +/** Replaces all state with an imported file. Returns an error message if the file is unusable. */ +export function importText(text: string): string | null { + const result = parseState(text) + if (!result.ok) return result.message + persistence.replace(result.state) + store.state = result.state + return null +}