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
+34
View File
@@ -0,0 +1,34 @@
import { EventFeed, SCHEMA_VERSION } from "../shared/feed.ts";
export type FeedState =
| { status: "loading" }
| { status: "ready"; feed: EventFeed }
| { status: "error"; message: string };
/**
* Fetch the published feed.
*
* A `schemaVersion` we do not recognise is refused rather than rendered — the
* client would be guessing at unfamiliar fields, and a calendar that guesses is
* worse than one that asks you to reload.
*/
export async function fetchFeed(signal?: AbortSignal): Promise<EventFeed> {
const res = await fetch("/data/events.v1.json", { signal: signal ?? null });
if (!res.ok) {
throw new Error(`Feed request failed (${res.status}).`);
}
const json: unknown = await res.json();
const version = (json as { schemaVersion?: number }).schemaVersion;
if (version !== SCHEMA_VERSION) {
throw new Error(
`This page expects feed v${SCHEMA_VERSION} but the server sent v${String(version)}. Reload to get the current app.`,
);
}
const parsed = EventFeed.safeParse(json);
if (!parsed.success) {
throw new Error("The feed did not match the expected shape.");
}
return parsed.data;
}
+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.
}
}
+55
View File
@@ -0,0 +1,55 @@
import { useCallback, useEffect, useState } from "react";
import { KEYS, readJson, writeJson } from "./storage.ts";
export interface Completion {
completedAt: string;
}
export type Completions = Record<string, Completion>;
/**
* Completion marks, keyed by event ID.
*
* Writes are optimistic and local — there is no round trip and no failure case
* to design for.
*/
export function useCompletions() {
const [completions, setCompletions] = useState<Completions>(() =>
readJson<Completions>(KEYS.completions, {}),
);
useEffect(() => {
writeJson(KEYS.completions, completions);
}, [completions]);
const toggle = useCallback((id: string) => {
setCompletions((prev) => {
if (prev[id] !== undefined) {
const { [id]: _removed, ...rest } = prev;
return rest;
}
return { ...prev, [id]: { completedAt: new Date().toISOString() } };
});
}, []);
/**
* Import merges and never removes. A completion present on either side stays
* completed — an import that silently wiped marks would be unrecoverable,
* since nothing else holds a copy.
*/
const merge = useCallback((incoming: Completions) => {
setCompletions((prev) => {
const next = { ...prev };
for (const [id, value] of Object.entries(incoming)) {
const existing = next[id];
// Keep the earlier of the two marks; union of IDs, never a removal.
next[id] =
existing === undefined || value.completedAt < existing.completedAt
? value
: existing;
}
return next;
});
}, []);
return { completions, toggle, merge };
}
+48
View File
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState } from "react";
import type { GameId, Region } from "../../shared/schema.ts";
import { guessRegion } from "../../shared/time.ts";
import { KEYS, readJson, writeJson } from "./storage.ts";
export interface Prefs {
region: Region;
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
hiddenGames: GameId[];
showCompleted: boolean;
/** False until the reader confirms or changes the guessed region. */
regionConfirmed: boolean;
}
function defaults(): Prefs {
return {
region: guessRegion(),
hiddenGames: [],
showCompleted: true,
regionConfirmed: false,
};
}
export function usePrefs() {
const [prefs, setPrefs] = useState<Prefs>(() => ({
...defaults(),
...readJson<Partial<Prefs>>(KEYS.prefs, {}),
}));
useEffect(() => {
writeJson(KEYS.prefs, prefs);
}, [prefs]);
const update = useCallback((patch: Partial<Prefs>) => {
setPrefs((prev) => ({ ...prev, ...patch }));
}, []);
const toggleGame = useCallback((game: GameId) => {
setPrefs((prev) => ({
...prev,
hiddenGames: prev.hiddenGames.includes(game)
? prev.hiddenGames.filter((g) => g !== game)
: [...prev.hiddenGames, game],
}));
}, []);
return { prefs, update, toggleGame };
}