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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -15,6 +15,8 @@ Svelte 5 + TypeScript + Vite. Public repository.
| Continue an unfinished PR | `docs/handoff/<file>.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/` |
Expand Down
29 changes: 29 additions & 0 deletions docs/decisions/007-state-store.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 9 additions & 1 deletion src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script lang="ts">
import SaveStatus from './components/SaveStatus.svelte'
import Settings from './components/Settings.svelte'
import { TABS, hashForTab, tabFromHash, type TabId } from './lib/tabs'

let active: TabId = $state(tabFromHash(location.hash))
Expand All @@ -21,8 +23,14 @@
</nav>
</header>

<SaveStatus />

<main>
<p class="placeholder">&gt; {TABS.find((tab) => tab.id === active)?.label}: not built yet_</p>
{#if active === 'settings'}
<Settings />
{:else}
<p class="placeholder">&gt; {TABS.find((tab) => tab.id === active)?.label}: not built yet_</p>
{/if}
</main>

<style>
Expand Down
1 change: 1 addition & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
--muted: #6f9a7a;
--accent: #7dff9b;
--line: #24402c;
--warn: #ffb454;

color-scheme: dark;
font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
Expand Down
37 changes: 37 additions & 0 deletions src/components/SaveStatus.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script lang="ts">
import { hashForTab } from '../lib/tabs'
import { store } from '../lib/store.svelte'

const status = $derived(store.status)
</script>

{#if status.kind === 'memory'}
<p class="warning" role="alert">
{status.reason === 'full' ? 'Browser storage is full.' : 'Browser storage is not available.'}
LSOS keeps working, but changes are lost when this tab closes.
<a href={hashForTab('settings')}>Export a backup</a> before closing.
</p>
{:else if status.kind === 'blocked'}
<p class="warning" role="alert">
{status.reason === 'newer'
? status.message
: `Your saved data could not be read. ${status.message}`}
It is kept as it is and will not be overwritten; changes are not saved for now.
<a href={hashForTab('settings')}>Export it</a> as a backup{status.reason === 'newer'
? ', or open the newer LSOS'
: ''}.
</p>
{/if}

<style>
.warning {
border: 1px solid var(--warn);
color: var(--warn);
margin: 0;
padding: 0.75rem 1rem;
}

.warning a {
color: inherit;
}
</style>
81 changes: 81 additions & 0 deletions src/components/Settings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script lang="ts">
import { exportFileName } from '../lib/state'
import { exportText, importText, store } from '../lib/store.svelte'

let fileInput: HTMLInputElement
let message: { text: string; error: boolean } | null = $state(null)

function exportBackup() {
const blob = new Blob([exportText()], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = exportFileName(new Date())
link.click()
// Revoking right away can cancel the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url))
message = { text: `Exported ${link.download}.`, error: false }
}

async function importBackup() {
const file = fileInput.files?.[0]
fileInput.value = ''
if (!file) return
const warning =
store.status.kind === 'blocked'
? 'This replaces the saved data LSOS could not read. Export it first if you may need it. Continue?'
: 'Replace all LSOS data with this file?'
if (!confirm(warning)) return
const error = importText(await file.text())
message = error
? { text: `Could not import ${file.name}: ${error}`, error: true }
: { text: `Imported ${file.name}.`, error: false }
}
</script>

<section>
<h2>Backup</h2>
<p>
Your data lives only in this browser. Export it to keep a copy or to move to another device.
</p>
<div class="actions">
<button type="button" onclick={exportBackup}>Export</button>
<button type="button" onclick={() => fileInput.click()}>Import</button>
<input
bind:this={fileInput}
type="file"
accept=".json,application/json"
hidden
onchange={importBackup}
/>
</div>
{#if message}
<p class:error={message.error} role="status">{message.text}</p>
{/if}
</section>

<style>
h2 {
font-size: 1rem;
text-transform: uppercase;
}

.actions {
display: flex;
gap: 0.5rem;
}

button {
background: none;
border: 1px solid var(--accent);
color: var(--accent);
cursor: pointer;
font: inherit;
padding: 0.25rem 0.75rem;
text-transform: uppercase;
}

.error {
color: var(--warn);
}
</style>
Loading
Loading