From ac165f0df777ec5e3574ba03540387d08c7c7990 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 00:28:45 +0200 Subject: [PATCH] feat: add local-only completion and preference state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/api.ts | 34 ++++++++++++++++++ src/client/state/storage.ts | 40 ++++++++++++++++++++++ src/client/state/useCompletions.ts | 55 ++++++++++++++++++++++++++++++ src/client/state/usePrefs.ts | 48 ++++++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/client/api.ts create mode 100644 src/client/state/storage.ts create mode 100644 src/client/state/useCompletions.ts create mode 100644 src/client/state/usePrefs.ts diff --git a/src/client/api.ts b/src/client/api.ts new file mode 100644 index 0000000..e7ca3a0 --- /dev/null +++ b/src/client/api.ts @@ -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 { + 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; +} diff --git a/src/client/state/storage.ts b/src/client/state/storage.ts new file mode 100644 index 0000000..022cbb7 --- /dev/null +++ b/src/client/state/storage.ts @@ -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(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. + } +} diff --git a/src/client/state/useCompletions.ts b/src/client/state/useCompletions.ts new file mode 100644 index 0000000..f60db62 --- /dev/null +++ b/src/client/state/useCompletions.ts @@ -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; + +/** + * 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(() => + readJson(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 }; +} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts new file mode 100644 index 0000000..b471555 --- /dev/null +++ b/src/client/state/usePrefs.ts @@ -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(() => ({ + ...defaults(), + ...readJson>(KEYS.prefs, {}), + })); + + useEffect(() => { + writeJson(KEYS.prefs, prefs); + }, [prefs]); + + const update = useCallback((patch: Partial) => { + 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 }; +}