feat: add local-only completion and preference state

Completions and prefs live in localStorage and never reach the server —
there is no account and nothing to log into. Reads never throw, so a corrupt
value costs a preference rather than the whole screen.

Import merges and never removes: a completion present on either side stays
completed, because nothing else holds a copy to restore from.

The feed client refuses a schemaVersion it does not know rather than
guessing at unfamiliar fields.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:28:45 +02:00
co-authored by Claude Opus 5
parent 49d52e3587
commit ac165f0df7
4 changed files with 177 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
/**
* localStorage access.
*
* Everything here stays on the device — there is no account, no session, and
* the server never learns what a user has completed. The `v1` segment in every
* key is the migration hook: read old versions forward, and never delete an old
* key until the migration has shipped and run, because a user who has not
* opened the app in six months still has their data under it.
*/
const NS = "gacha-tracker:v1";
export const KEYS = {
completions: `${NS}:completions`,
prefs: `${NS}:prefs`,
} as const;
/**
* Reads never throw. A corrupt or foreign value falls back to the default
* rather than taking the app down — losing a preference is recoverable, a blank
* screen is not.
*/
export function readJson<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function writeJson(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// Quota exceeded or storage disabled (private mode). The UI keeps working
// from in-memory state; only persistence is lost.
}
}