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
+35 -11
View File
@@ -8,7 +8,8 @@ import { Timeline } from "./components/Timeline.tsx";
import { Welcome } from "./components/Welcome.tsx"; import { Welcome } from "./components/Welcome.tsx";
import { Colophon } from "./components/Colophon.tsx"; import { Colophon } from "./components/Colophon.tsx";
import { Legend } from "./components/Legend.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 { usePrefs } from "./state/usePrefs.ts";
import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts"; import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts";
import type { GameId } from "../shared/schema.ts"; import type { GameId } from "../shared/schema.ts";
@@ -54,7 +55,10 @@ export function App() {
const now = useNow(); const now = useNow();
const online = useOnline(); const online = useOnline();
const { prefs, update, toggleGame } = usePrefs(); 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(() => { useEffect(() => {
const ac = new AbortController(); const ac = new AbortController();
@@ -90,9 +94,12 @@ export function App() {
allRows allRows
.filter((r) => !prefs.hiddenGames.includes(r.event.game)) .filter((r) => !prefs.hiddenGames.includes(r.event.game))
.filter((r) => !r.clock.ended) .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) .filter((r) => prefs.showCompleted || completions[r.event.id] === undefined)
.sort(endingSoonestFirst), .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); const live = visible.filter((r) => r.clock.live);
@@ -247,8 +254,11 @@ export function App() {
prefs={prefs} prefs={prefs}
onToggleGame={toggleGame} onToggleGame={toggleGame}
onUpdate={update} onUpdate={update}
onExport={() => exportProgress(completions, prefs)} ignoredCount={Object.keys(ignored.marks).length}
onImport={(file) => void importProgress(file, merge)} onExport={() => exportProgress(completions, ignored.marks, prefs)}
onImport={(file) =>
void importProgress(file, completed.merge, ignored.merge)
}
/> />
{!online && ( {!online && (
@@ -267,6 +277,8 @@ export function App() {
<EventDetail <EventDetail
row={openRow} row={openRow}
completed={completions[openRow.event.id] !== undefined} completed={completions[openRow.event.id] !== undefined}
ignored={ignored.marks[openRow.event.id] !== undefined}
onIgnore={ignored.toggle}
onToggle={toggle} onToggle={toggle}
onClose={() => setOpenId(null)} onClose={() => setOpenId(null)}
/> />
@@ -307,7 +319,8 @@ function Section({
} }
function exportProgress( function exportProgress(
completions: Record<string, { completedAt: string }>, completions: Record<string, { at: string }>,
ignored: Record<string, { at: string }>,
prefs: unknown, prefs: unknown,
) { ) {
const blob = new Blob( const blob = new Blob(
@@ -318,6 +331,7 @@ function exportProgress(
version: 1, version: 1,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
completions, completions,
ignored,
prefs, prefs,
}, },
null, null,
@@ -336,18 +350,28 @@ function exportProgress(
async function importProgress( async function importProgress(
file: File, file: File,
merge: (c: Record<string, { completedAt: string }>) => void, mergeCompleted: (c: Record<string, { at: string }>) => void,
mergeIgnored: (c: Record<string, { at: string }>) => void,
) { ) {
try { try {
const parsed: unknown = JSON.parse(await file.text()); 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") { if (data.format !== "gacha-tracker-export") {
alert("That file isn't an Event Clock export."); alert("That file isn't an Event Clock export.");
return; return;
} }
if (typeof data.completions === "object" && data.completions !== null) { const asMarks = (v: unknown) =>
merge(data.completions as Record<string, { completedAt: string }>); typeof v === "object" && v !== null
} ? (v as Record<string, { at: string }>)
: null;
const c = asMarks(data.completions);
const i = asMarks(data.ignored);
if (c !== null) mergeCompleted(c);
if (i !== null) mergeIgnored(i);
} catch { } catch {
alert("That file couldn't be read. Export a fresh copy and try again."); alert("That file couldn't be read. Export a fresh copy and try again.");
} }
+26 -9
View File
@@ -13,6 +13,7 @@ export function Controls({
prefs, prefs,
onToggleGame, onToggleGame,
onUpdate, onUpdate,
ignoredCount,
onExport, onExport,
onImport, onImport,
}: { }: {
@@ -20,6 +21,7 @@ export function Controls({
prefs: Prefs; prefs: Prefs;
onToggleGame: (g: GameId) => void; onToggleGame: (g: GameId) => void;
onUpdate: (p: Partial<Prefs>) => void; onUpdate: (p: Partial<Prefs>) => void;
ignoredCount: number;
onExport: () => void; onExport: () => void;
onImport: (file: File) => void; onImport: (file: File) => void;
}) { }) {
@@ -73,15 +75,30 @@ export function Controls({
</div> </div>
</div> </div>
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted"> <div className="flex flex-col gap-2">
<input <label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
type="checkbox" <input
checked={prefs.showCompleted} type="checkbox"
onChange={(e) => onUpdate({ showCompleted: e.target.checked })} checked={prefs.showCompleted}
className="size-4 accent-[var(--color-near)]" onChange={(e) => onUpdate({ showCompleted: e.target.checked })}
/> className="size-4 accent-[var(--color-near)]"
Show events I've finished />
</label> Show events I've finished
</label>
{ignoredCount > 0 && (
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={prefs.showIgnored}
onChange={(e) => onUpdate({ showIgnored: e.target.checked })}
className="size-4 accent-[var(--color-near)]"
/>
Show the {ignoredCount} event{ignoredCount > 1 ? "s" : ""} I'm
ignoring
</label>
)}
</div>
</div> </div>
<div className="mt-6 border-t border-hairline pt-4"> <div className="mt-6 border-t border-hairline pt-4">
+19
View File
@@ -7,12 +7,16 @@ import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export function EventDetail({ export function EventDetail({
row, row,
completed, completed,
ignored,
onToggle, onToggle,
onIgnore,
onClose, onClose,
}: { }: {
row: RowEvent; row: RowEvent;
completed: boolean; completed: boolean;
ignored: boolean;
onToggle: (id: string) => void; onToggle: (id: string) => void;
onIgnore: (id: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const { event, clock } = row; const { event, clock } = row;
@@ -109,6 +113,21 @@ export function EventDetail({
Source Source
</a> </a>
</div> </div>
{/* Ignoring is not completing. "Done" keeps an event visible and
counted; "not interested" removes it from both views entirely. */}
<button
type="button"
onClick={() => {
onIgnore(event.id);
if (!ignored) onClose();
}}
className="mt-3 w-full rounded-lg px-4 py-2 text-xs text-faint transition-colors hover:text-muted"
>
{ignored
? "Stop ignoring this event"
: "Not interested — hide this event"}
</button>
</div> </div>
</div> </div>
); );
+1
View File
@@ -12,6 +12,7 @@ const NS = "gacha-tracker:v1";
export const KEYS = { export const KEYS = {
completions: `${NS}:completions`, completions: `${NS}:completions`,
ignored: `${NS}:ignored`,
prefs: `${NS}:prefs`, prefs: `${NS}:prefs`,
} as const; } 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. */ /** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
hiddenGames: GameId[]; hiddenGames: GameId[];
showCompleted: boolean; 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. */ /** False until the reader confirms or changes the guessed region. */
regionConfirmed: boolean; regionConfirmed: boolean;
/** False until the reader has picked their games on first run. */ /** False until the reader has picked their games on first run. */
@@ -19,6 +21,7 @@ function defaults(): Prefs {
region: guessRegion(), region: guessRegion(),
hiddenGames: [], hiddenGames: [],
showCompleted: true, showCompleted: true,
showIgnored: false,
regionConfirmed: false, regionConfirmed: false,
onboarded: false, onboarded: false,
}; };