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:
co-authored by
Claude Opus 5
parent
49d52e3587
commit
ac165f0df7
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user