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 { 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() {
|
||||
<EventDetail
|
||||
row={openRow}
|
||||
completed={completions[openRow.event.id] !== undefined}
|
||||
ignored={ignored.marks[openRow.event.id] !== undefined}
|
||||
onIgnore={ignored.toggle}
|
||||
onToggle={toggle}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
@@ -307,7 +319,8 @@ function Section({
|
||||
}
|
||||
|
||||
function exportProgress(
|
||||
completions: Record<string, { completedAt: string }>,
|
||||
completions: Record<string, { at: string }>,
|
||||
ignored: Record<string, { at: string }>,
|
||||
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<string, { completedAt: string }>) => void,
|
||||
mergeCompleted: (c: Record<string, { at: string }>) => void,
|
||||
mergeIgnored: (c: Record<string, { at: string }>) => 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<string, { completedAt: string }>);
|
||||
}
|
||||
const asMarks = (v: unknown) =>
|
||||
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 {
|
||||
alert("That file couldn't be read. Export a fresh copy and try again.");
|
||||
}
|
||||
|
||||
@@ -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<Prefs>) => void;
|
||||
ignoredCount: number;
|
||||
onExport: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}) {
|
||||
@@ -73,15 +75,30 @@ export function Controls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.showCompleted}
|
||||
onChange={(e) => onUpdate({ showCompleted: e.target.checked })}
|
||||
className="size-4 accent-[var(--color-near)]"
|
||||
/>
|
||||
Show events I've finished
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.showCompleted}
|
||||
onChange={(e) => onUpdate({ showCompleted: e.target.checked })}
|
||||
className="size-4 accent-[var(--color-near)]"
|
||||
/>
|
||||
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 className="mt-6 border-t border-hairline pt-4">
|
||||
|
||||
@@ -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
|
||||
</a>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ const NS = "gacha-tracker:v1";
|
||||
|
||||
export const KEYS = {
|
||||
completions: `${NS}:completions`,
|
||||
ignored: `${NS}:ignored`,
|
||||
prefs: `${NS}:prefs`,
|
||||
} 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. */
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user