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:
co-authored by
Claude Opus 5
parent
dd596b5a43
commit
71bff72ee9
+35
-11
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user