diff --git a/src/client/App.tsx b/src/client/App.tsx index 6284eda..b601d9d 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -8,7 +8,8 @@ import { Timeline } from "./components/Timeline.tsx"; import { Welcome } from "./components/Welcome.tsx"; import { Colophon } from "./components/Colophon.tsx"; import { Legend } from "./components/Legend.tsx"; -import { useCompletions } from "./state/useCompletions.ts"; +import { KEYS } from "./state/storage.ts"; +import { useMarkSet } from "./state/useMarkSet.ts"; import { usePrefs } from "./state/usePrefs.ts"; import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts"; import type { GameId } from "../shared/schema.ts"; @@ -54,7 +55,10 @@ export function App() { const now = useNow(); const online = useOnline(); const { prefs, update, toggleGame } = usePrefs(); - const { completions, toggle, merge } = useCompletions(); + const completed = useMarkSet(KEYS.completions); + const ignored = useMarkSet(KEYS.ignored); + const completions = completed.marks; + const toggle = completed.toggle; useEffect(() => { const ac = new AbortController(); @@ -90,9 +94,12 @@ export function App() { allRows .filter((r) => !prefs.hiddenGames.includes(r.event.game)) .filter((r) => !r.clock.ended) + // Ignored events are gone from both views unless deliberately revealed + // — that is the whole point of ignoring one. + .filter((r) => prefs.showIgnored || ignored.marks[r.event.id] === undefined) .filter((r) => prefs.showCompleted || completions[r.event.id] === undefined) .sort(endingSoonestFirst), - [allRows, prefs.hiddenGames, prefs.showCompleted, completions], + [allRows, prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, completions, ignored.marks], ); const live = visible.filter((r) => r.clock.live); @@ -247,8 +254,11 @@ export function App() { prefs={prefs} onToggleGame={toggleGame} onUpdate={update} - onExport={() => exportProgress(completions, prefs)} - onImport={(file) => void importProgress(file, merge)} + ignoredCount={Object.keys(ignored.marks).length} + onExport={() => exportProgress(completions, ignored.marks, prefs)} + onImport={(file) => + void importProgress(file, completed.merge, ignored.merge) + } /> {!online && ( @@ -267,6 +277,8 @@ export function App() { setOpenId(null)} /> @@ -307,7 +319,8 @@ function Section({ } function exportProgress( - completions: Record, + completions: Record, + ignored: Record, prefs: unknown, ) { const blob = new Blob( @@ -318,6 +331,7 @@ function exportProgress( version: 1, exportedAt: new Date().toISOString(), completions, + ignored, prefs, }, null, @@ -336,18 +350,28 @@ function exportProgress( async function importProgress( file: File, - merge: (c: Record) => void, + mergeCompleted: (c: Record) => void, + mergeIgnored: (c: Record) => void, ) { try { const parsed: unknown = JSON.parse(await file.text()); - const data = parsed as { format?: string; completions?: unknown }; + const data = parsed as { + format?: string; + completions?: unknown; + ignored?: unknown; + }; if (data.format !== "gacha-tracker-export") { alert("That file isn't an Event Clock export."); return; } - if (typeof data.completions === "object" && data.completions !== null) { - merge(data.completions as Record); - } + const asMarks = (v: unknown) => + typeof v === "object" && v !== null + ? (v as Record) + : null; + const c = asMarks(data.completions); + const i = asMarks(data.ignored); + if (c !== null) mergeCompleted(c); + if (i !== null) mergeIgnored(i); } catch { alert("That file couldn't be read. Export a fresh copy and try again."); } diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index dc80c0f..e3e33aa 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -13,6 +13,7 @@ export function Controls({ prefs, onToggleGame, onUpdate, + ignoredCount, onExport, onImport, }: { @@ -20,6 +21,7 @@ export function Controls({ prefs: Prefs; onToggleGame: (g: GameId) => void; onUpdate: (p: Partial) => void; + ignoredCount: number; onExport: () => void; onImport: (file: File) => void; }) { @@ -73,15 +75,30 @@ export function Controls({ - +
+ + + {ignoredCount > 0 && ( + + )} +
diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx index 061644d..9004cdb 100644 --- a/src/client/components/EventDetail.tsx +++ b/src/client/components/EventDetail.tsx @@ -7,12 +7,16 @@ import { Meter, URGENCY_COLOR } from "./Meter.tsx"; export function EventDetail({ row, completed, + ignored, onToggle, + onIgnore, onClose, }: { row: RowEvent; completed: boolean; + ignored: boolean; onToggle: (id: string) => void; + onIgnore: (id: string) => void; onClose: () => void; }) { const { event, clock } = row; @@ -109,6 +113,21 @@ export function EventDetail({ Source
+ + {/* Ignoring is not completing. "Done" keeps an event visible and + counted; "not interested" removes it from both views entirely. */} + ); diff --git a/src/client/state/storage.ts b/src/client/state/storage.ts index 022cbb7..07e324e 100644 --- a/src/client/state/storage.ts +++ b/src/client/state/storage.ts @@ -12,6 +12,7 @@ const NS = "gacha-tracker:v1"; export const KEYS = { completions: `${NS}:completions`, + ignored: `${NS}:ignored`, prefs: `${NS}:prefs`, } as const; diff --git a/src/client/state/useCompletions.ts b/src/client/state/useCompletions.ts deleted file mode 100644 index f60db62..0000000 --- a/src/client/state/useCompletions.ts +++ /dev/null @@ -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; - -/** - * 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 index 8332cf9..5ef9886 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -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, };