feat: let readers ignore events they don't care about

Ignoring is not completing. "Done" keeps an event visible and counted;
"not interested" removes it from both views, which is the entire point.

Completions and ignores are the same shape and want the same guarantees, so
they now share one mark-set implementation — union merge on import, never a
removal, since nothing else holds a copy. Both ride in the export file.

Ignored events stay recoverable: the count and a reveal toggle appear in
settings once there is something to reveal.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:18:11 +02:00
co-authored by Claude Opus 5
parent dd596b5a43
commit 71bff72ee9
6 changed files with 84 additions and 75 deletions
+1
View File
@@ -12,6 +12,7 @@ const NS = "gacha-tracker:v1";
export const KEYS = {
completions: `${NS}:completions`,
ignored: `${NS}:ignored`,
prefs: `${NS}:prefs`,
} as const;
-55
View File
@@ -1,55 +0,0 @@
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 };
}
+3
View File
@@ -8,6 +8,8 @@ export interface Prefs {
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
hiddenGames: GameId[];
showCompleted: boolean;
/** Reveal events the reader has ignored, so they can be restored. */
showIgnored: boolean;
/** False until the reader confirms or changes the guessed region. */
regionConfirmed: boolean;
/** False until the reader has picked their games on first run. */
@@ -19,6 +21,7 @@ function defaults(): Prefs {
region: guessRegion(),
hiddenGames: [],
showCompleted: true,
showIgnored: false,
regionConfirmed: false,
onboarded: false,
};