Adds the two localStorage stores behind PRD F13 and joins their events to the feed's in App, so they sort, filter, focus, expire and tick through exactly the same code paths rather than a parallel set. Three decisions worth naming: - Export carries customGames and customEvents. These exist in one browser and nowhere else — not in the feed, not on a server — so an export without them would be a backup that loses the half the reader typed. Import merges by id like every other set and never removes. - A date is read in the reader's timezone, and a bare end date means the end of that day. Someone who types 20 Aug means the 20th where they are; the feed's 00:00Z day boundaries are a parser declining to guess a time the source never printed, which is a different situation from being told directly. - An impossible date is refused rather than rolled over, because Date.parse turns 30 February into 2 March and a silently shifted date is the failure this product exists to prevent. Deleting a game is refused while it still holds events, and deleting an event leaves its marks and logged days alone — reaching into three stores on one tap is how a misclick costs somebody a streak. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
63 lines
2.0 KiB
TypeScript
63 lines
2.0 KiB
TypeScript
/**
|
|
* 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 = {
|
|
/**
|
|
* Superseded by `progress`, which carries a status rather than using
|
|
* membership to mean "done". Read once to migrate; never written, never
|
|
* deleted — see useProgress.
|
|
*/
|
|
completions: `${NS}:completions`,
|
|
progress: `${NS}:progress`,
|
|
/**
|
|
* Which game-days of a repeating event the reader has ticked off. Separate
|
|
* from `progress` because it is a growing list per event, not one record.
|
|
*/
|
|
daily: `${NS}:daily`,
|
|
ignored: `${NS}:ignored`,
|
|
prefs: `${NS}:prefs`,
|
|
/**
|
|
* Games and events the reader entered themselves (PRD F13).
|
|
*
|
|
* Two keys rather than one because they have different lifetimes: a game
|
|
* outlives the events in it, and deleting one is refused while the other
|
|
* still references it. Like everything else here, this is the only copy —
|
|
* there is no server that has ever seen it.
|
|
*/
|
|
customGames: `${NS}:customGames`,
|
|
customEvents: `${NS}:customEvents`,
|
|
} 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.
|
|
}
|
|
}
|