feat(filter): focus one game at a time

Switching games on and off says which games you play, and is set once.
It's the wrong tool for what a player of four games does while reading —
clear one game, move to the next — which cost two taps per game and left
the settings panel no longer describing what they play.

Focus is a lens over that filter, not a second filter: a bar at the top,
above everything it narrows, with a "next game" control that steps
through and ends by returning to all. It never touches hiddenGames, and
a focus on a game since switched off is ignored rather than obeyed, so
it can't strand you on a blank page whose cause is elsewhere. Each chip
carries that game's outstanding count, so a game with nothing waiting
says so before you visit it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-16 20:42:42 +02:00
co-authored by Claude Opus 5
parent 2a7a117705
commit d70720e2af
11 changed files with 350 additions and 24 deletions
+2 -1
View File
@@ -81,9 +81,10 @@ src/ingest/ html.ts, dates.ts (six formats), merge.ts, sanitize.ts, robots
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
src/client/ React app, service worker, manifest src/client/ React app, service worker, manifest
state/ progress, daily log, ignores, prefs, sort — all localStorage state/ progress, daily log, ignores, prefs, sort — all localStorage
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
serve.ts static server + /api/health serve.ts static server + /api/health
test/ 309 tests test/ 318 tests
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
snapshots/ current page per source, rewritten by refresh — see its README snapshots/ current page per source, rewritten by refresh — see its README
``` ```
+4 -3
View File
@@ -88,9 +88,10 @@ src/
Timeline.tsx calendar lanes (F1) Timeline.tsx calendar lanes (F1)
EventDetail.tsx detail sheet, ignore action (F9) EventDetail.tsx detail sheet, ignore action (F9)
ProgressControls status, effort, note (F12) ProgressControls status, effort, note (F12)
Dailies.tsx today's strip of repeating jobs Dailies.tsx today's strip, per-game reset clocks
DailyChecklist one repeating event's whole run DailyChecklist one repeating event's whole run
Fireworks.tsx the burst when the last daily lands Fireworks.tsx the burst when the last daily lands
GameFocus.tsx one game at a time (F4a)
Controls.tsx games, region, export/import(F4, F5, F6) Controls.tsx games, region, export/import(F4, F5, F6)
Welcome.tsx first-run game picker (F8) Welcome.tsx first-run game picker (F8)
Toast.tsx undo an ignore Toast.tsx undo an ignore
@@ -100,9 +101,9 @@ src/
useMarkSet.ts ignores (and the superseded completions shape) useMarkSet.ts ignores (and the superseded completions shape)
useProgress.ts status, effort, note, daily override (F12) useProgress.ts status, effort, note, daily override (F12)
useDailyLog.ts which game-days are ticked off useDailyLog.ts which game-days are ticked off
usePrefs.ts region, filters, onboarding flags usePrefs.ts region, filters, focus, onboarding flags
sort.ts deadline order, or what you're partway through sort.ts deadline order, or what you're partway through
lens.ts who sees which rows — outstanding, next-to-expire lens.ts who sees which rows — focus, outstanding, next-to-expire
serve.ts static server + /api/health ✓ built serve.ts static server + /api/health ✓ built
scripts/ scripts/
build-feed.ts fixtures → public/data/events.v1.json ✓ built build-feed.ts fixtures → public/data/events.v1.json ✓ built
+2 -2
View File
@@ -199,8 +199,8 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
"gacha-tracker:v1:progress" // { [eventId]: { status?, effort?, note?, at } } "gacha-tracker:v1:progress" // { [eventId]: { status?, effort?, note?, at } }
"gacha-tracker:v1:daily" // { [id]: { days: ["2026-08-15", ...], at } } "gacha-tracker:v1:daily" // { [id]: { days: ["2026-08-15", ...], at } }
"gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this" "gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this"
"gacha-tracker:v1:prefs" // { region, hiddenGames[], sort, detectDaily, showCompleted, "gacha-tracker:v1:prefs" // { region, hiddenGames[], focusGame, sort, detectDaily,
// showIgnored, regionConfirmed, onboarded } // showCompleted, showIgnored, regionConfirmed, onboarded }
"gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written "gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written
``` ```
+14
View File
@@ -73,6 +73,20 @@ but de-emphasized; a filter toggles them out entirely.
Filter by game (multi-select, persisted) and by event type. Hiding a game hides it from both views. Filter by game (multi-select, persisted) and by event type. Hiding a game hides it from both views.
Preferences persist in `localStorage`. Preferences persist in `localStorage`.
**F4a — Focus one game at a time.**
Switching games on and off says *which games the reader plays*, and is set once. It is the wrong
tool for the thing a player of four games actually does while reading: clear one game, move to the
next. Doing that with the on/off switches costs two taps per game and leaves the settings panel no
longer describing what they play.
So focus is a **lens over the filter, not a second filter**: a bar at the top of the page, above
everything it affects, narrowing every view — headline, dailies, lists, calendar and counts — to one
game, with a "next game" control that steps through them and ends by returning to all. It never
changes `hiddenGames`, "All" is always one tap away, and a focus on a game that is switched off or
has left the feed is ignored rather than obeyed, so it can never strand the reader on a blank page
whose cause is elsewhere. Each chip carries that game's outstanding count, so a game with nothing
waiting says so before it is visited.
**F5 — Region selection.** **F5 — Region selection.**
A user picks Asia / America / Europe once. For events where `regionScoped` is true, all displayed A user picks Asia / America / Europe once. For events where `regionScoped` is true, all displayed
end times resolve to that region's server reset. This is stored in `localStorage` and defaults to a end times resolve to that region's server reset. This is stored in `localStorage` and defaults to a
+68 -12
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { fetchFeed, type FeedState } from "./api.ts"; import { fetchFeed, type FeedState } from "./api.ts";
import { Controls } from "./components/Controls.tsx"; import { Controls } from "./components/Controls.tsx";
import { Dailies } from "./components/Dailies.tsx"; import { Dailies } from "./components/Dailies.tsx";
import { GameFocus } from "./components/GameFocus.tsx";
import { EventDetail } from "./components/EventDetail.tsx"; import { EventDetail } from "./components/EventDetail.tsx";
import { EventRow, type DailyBadge, type RowEvent } from "./components/EventRow.tsx"; import { EventRow, type DailyBadge, type RowEvent } from "./components/EventRow.tsx";
import { NextUp } from "./components/NextUp.tsx"; import { NextUp } from "./components/NextUp.tsx";
@@ -16,9 +17,16 @@ import { useProgress } from "./state/useProgress.ts";
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
import { usePrefs } from "./state/usePrefs.ts"; import { usePrefs } from "./state/usePrefs.ts";
import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts"; import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts";
import { firstToExpire, outstanding } from "./state/lens.ts"; import {
advanceFocus,
countByGame,
firstToExpire,
outstanding,
resolveFocus,
} from "./state/lens.ts";
import { clockFor, DAY, formatRemaining } from "../shared/time.ts"; import { clockFor, DAY, formatRemaining } from "../shared/time.ts";
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
import { gameMeta } from "../shared/games.ts";
import type { GameId } from "../shared/schema.ts"; import type { GameId } from "../shared/schema.ts";
type View = "soon" | "calendar"; type View = "soon" | "calendar";
@@ -148,7 +156,24 @@ export function App() {
[allRows], [allRows],
); );
const visible = useMemo( /** Games the reader plays, in feed order. The focus bar rotates through these. */
const enabled = useMemo(
() => games.filter((g) => !prefs.hiddenGames.includes(g)),
[games, prefs.hiddenGames],
);
// A focus on a game they have since switched off is ignored, not obeyed —
// otherwise the page is blank for a reason that lives in a panel at the
// bottom. The stored value is left alone so switching the game back on
// restores where they were.
const focus = resolveFocus(prefs.focusGame, enabled);
/**
* Everything the reader could be looking at, before focus narrows it. The
* focus chips count off this, so a chip can say what is waiting in a game
* that is not the one currently on screen.
*/
const inScope = useMemo(
() => () =>
allRows allRows
.filter((r) => !prefs.hiddenGames.includes(r.event.game)) .filter((r) => !prefs.hiddenGames.includes(r.event.game))
@@ -156,22 +181,27 @@ export function App() {
// Ignored events are gone from both views unless deliberately revealed // Ignored events are gone from both views unless deliberately revealed
// — that is the whole point of ignoring one. // — that is the whole point of ignoring one.
.filter((r) => prefs.showIgnored || !isIgnored(r.event.id)) .filter((r) => prefs.showIgnored || !isIgnored(r.event.id))
.filter((r) => prefs.showCompleted || !isDone(r.event.id)) .filter((r) => prefs.showCompleted || !isDone(r.event.id)),
// Sorting only ever groups: both modes fall back to soonest-ending
// inside a group, so choosing one never costs the deadline order.
.sort(compareRows(prefs.sort, activityOf)),
[ [
allRows, allRows,
prefs.hiddenGames, prefs.hiddenGames,
prefs.showCompleted, prefs.showCompleted,
prefs.showIgnored, prefs.showIgnored,
prefs.sort,
prog.progress, prog.progress,
daily.logs,
ignored.marks, ignored.marks,
], ],
); );
const visible = useMemo(
() =>
inScope
.filter((r) => focus === null || r.event.game === focus)
// Sorting only ever groups: both modes fall back to soonest-ending
// inside a group, so choosing one never costs the deadline order.
.sort(compareRows(prefs.sort, activityOf)),
[inScope, focus, prefs.sort, prog.progress, daily.logs],
);
const live = visible.filter((r) => r.clock.live); const live = visible.filter((r) => r.clock.live);
const upcoming = visible.filter((r) => r.clock.upcoming); const upcoming = visible.filter((r) => r.clock.upcoming);
@@ -188,6 +218,14 @@ export function App() {
const todo = outstanding(live, isDone, isIgnored); const todo = outstanding(live, isDone, isIgnored);
const next = firstToExpire(todo); const next = firstToExpire(todo);
// Counted across every game the reader plays, not just the focused one — a
// chip has to say what is waiting behind it to be worth tapping.
const scopedTodo = useMemo(
() => outstanding(inScope, isDone, isIgnored),
[inScope, prog.progress, ignored.marks],
);
const perGame = useMemo(() => countByGame(scopedTodo), [scopedTodo]);
const openRow = allRows.find((r) => r.event.id === openId) ?? null; const openRow = allRows.find((r) => r.event.id === openId) ?? null;
if (state.status === "loading") { if (state.status === "loading") {
@@ -274,14 +312,31 @@ export function App() {
</div> </div>
</header> </header>
{/* Working through games one at a time, which is how someone with four
of them actually plays: clear one, move on. Above everything it
filters, so what it is doing to the page is never a mystery. */}
<GameFocus
games={enabled}
focus={focus}
counts={perGame}
total={scopedTodo.length}
next={advanceFocus(focus, enabled)}
onFocus={(focusGame) => update({ focusGame })}
onAdvance={() => update({ focusGame: advanceFocus(focus, enabled) })}
/>
{view === "soon" ? ( {view === "soon" ? (
<> <>
<NextUp row={next} onOpen={setOpenId} /> <NextUp
row={next}
focused={focus === null ? null : gameMeta(focus).name}
onOpen={setOpenId}
/>
{/* The chores no wiki publishes, and the only thing on this page {/* The chores no wiki publishes, and the only thing on this page
that expires tonight rather than next patch. */} that expires tonight rather than next patch. */}
<Dailies <Dailies
games={games.filter((g) => !prefs.hiddenGames.includes(g))} games={focus === null ? enabled : [focus]}
events={todo.filter(repeatsDaily).map((r) => r.event)} events={todo.filter(repeatsDaily).map((r) => r.event)}
region={prefs.region} region={prefs.region}
now={now} now={now}
@@ -357,8 +412,9 @@ export function App() {
{visible.length === 0 && ( {visible.length === 0 && (
<p className="px-4 py-12 text-sm leading-relaxed text-muted"> <p className="px-4 py-12 text-sm leading-relaxed text-muted">
Nothing to show. Every game is switched off, or you've finished {focus !== null
everything and hidden completed events. ? `Nothing running in ${gameMeta(focus).name}. Try another game, or show all of them.`
: "Nothing to show. Every game is switched off, or you've finished everything and hidden completed events."}
</p> </p>
)} )}
</> </>
+3 -3
View File
@@ -73,9 +73,9 @@ export function Dailies({
previous.current = { total, complete }; previous.current = { total, complete };
if (was === null || !allDone) return; if (was === null || !allDone) return;
// Celebrate finishing the last one, and only that. The list also gets // Celebrate finishing the last one, and only that. The list also gets
// shorter when the reader marks a repeating event done, which can land on // shorter when the reader focuses a single game or marks a repeating event
// "all complete" without them having ticked anything — a burst there is the // done, which can land on "all complete" without them having ticked
// app congratulating them for filtering. // anything — a burst there is the app congratulating them for filtering.
if (was.total === total && complete > was.complete) setBurst((n) => n + 1); if (was.total === total && complete > was.complete) setBurst((n) => n + 1);
}, [total, complete, allDone]); }, [total, complete, allDone]);
+132
View File
@@ -0,0 +1,132 @@
import { gameMeta } from "../../shared/games.ts";
import type { GameId } from "../../shared/schema.ts";
/**
* One game at a time.
*
* The settings panel already lets a reader switch games on and off, but that is
* a different job: it says which games they play, and it is set once. This is
* the thing a reader does *while reading* — clear the deck down to one game,
* finish it, move to the next. Doing that with the on/off switches means two
* taps per game and a settings panel that no longer describes what they play.
*
* So focus is a lens, not a setting: it never changes which games are switched
* on, "All" is always one tap away, and the rotation ends by returning to All
* rather than looping forever.
*/
export function GameFocus({
games,
focus,
counts,
total,
next,
onFocus,
onAdvance,
}: {
/** Games the reader has switched on, in feed order. */
games: GameId[];
focus: GameId | null;
/** Outstanding rows per game, so a chip says whether it is worth a visit. */
counts: Partial<Record<GameId, number>>;
total: number;
/** Where "next" goes — null means back to all games. */
next: GameId | null;
onFocus: (game: GameId | null) => void;
onAdvance: () => void;
}) {
// With one game there is nothing to focus down to, and the bar would just be
// a chip that does nothing.
if (games.length < 2) return null;
return (
<section className="border-b border-hairline px-4 py-3">
<div className="flex items-baseline justify-between gap-3">
<p className="eyebrow">Focus</p>
<button
type="button"
onClick={onAdvance}
className="shrink-0 text-[0.6875rem] font-medium text-faint transition-colors hover:text-ink"
>
{next === null ? "Show all games" : `Next: ${gameMeta(next).short}`}
<span aria-hidden> </span>
</button>
</div>
<div
role="group"
aria-label="Focus on one game"
className="scroll-x -mx-4 mt-2 flex gap-1.5 px-4 pb-1"
>
<Chip
label="All"
count={total}
on={focus === null}
hue="var(--color-ink)"
onClick={() => onFocus(null)}
/>
{games.map((id) => {
const game = gameMeta(id);
return (
<Chip
key={id}
label={game.short}
ariaLabel={game.name}
count={counts[id] ?? 0}
on={focus === id}
hue={game.hue}
// Tapping the focused game backs out to all of them, so the chip
// that got you here is also the way back.
onClick={() => onFocus(focus === id ? null : id)}
/>
);
})}
</div>
</section>
);
}
/**
* A game chip.
*
* Carries its hue whether or not it is selected — dimmer when it is not — so
* the row reads as a set of games at a glance rather than as one coloured chip
* among a row of grey ones. The count is what makes it worth tapping: a game
* with nothing outstanding says so before you visit it.
*/
function Chip({
label,
ariaLabel,
count,
on,
hue,
onClick,
}: {
label: string;
ariaLabel?: string | undefined;
count: number;
on: boolean;
hue: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={on}
aria-label={`${ariaLabel ?? label}, ${count} outstanding`}
className="flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
style={{
borderColor: on ? hue : `color-mix(in srgb, ${hue} 30%, transparent)`,
color: on ? hue : "var(--color-muted)",
background: on
? `color-mix(in srgb, ${hue} 14%, transparent)`
: "transparent",
// Nothing to do here — still reachable, just not competing for the eye.
opacity: count === 0 && !on ? 0.5 : 1,
}}
>
{label}
<span className="tnum text-[0.625rem] opacity-70">{count}</span>
</button>
);
}
+6 -2
View File
@@ -12,6 +12,7 @@ import { Meter, URGENCY_COLOR } from "./Meter.tsx";
*/ */
export function NextUp({ export function NextUp({
row, row,
focused,
onOpen, onOpen,
}: { }: {
/** /**
@@ -21,6 +22,8 @@ export function NextUp({
* they have chosen to keep it elsewhere. * they have chosen to keep it elsewhere.
*/ */
row: RowEvent | null; row: RowEvent | null;
/** Name of the game being focused on, when the page is narrowed to one. */
focused: string | null;
onOpen: (id: string) => void; onOpen: (id: string) => void;
}) { }) {
if (row === null) { if (row === null) {
@@ -28,8 +31,9 @@ export function NextUp({
<section className="border-b border-hairline px-4 py-8"> <section className="border-b border-hairline px-4 py-8">
<p className="eyebrow">Nothing running</p> <p className="eyebrow">Nothing running</p>
<p className="mt-2 max-w-sm text-sm text-muted"> <p className="mt-2 max-w-sm text-sm text-muted">
Nothing live and unfinished in the games you have switched on. Turn a {focused === null
game back on below, or check again after the next patch. ? "Nothing live and unfinished in the games you have switched on. Turn a game back on below, or check again after the next patch."
: `Nothing live and unfinished in ${focused}. Move to the next game, or show all of them.`}
</p> </p>
</section> </section>
); );
+43
View File
@@ -62,3 +62,46 @@ export function firstToExpire<T extends Row>(rows: readonly T[]): T | null {
} }
return best ?? rows[0] ?? null; return best ?? rows[0] ?? null;
} }
/**
* The focused game, if it is still a game the reader can see.
*
* A focus on a game they have since switched off, or that has dropped out of
* the feed, is ignored rather than obeyed — the alternative is an empty page
* whose reason is a setting two screens away.
*/
export function resolveFocus(
focus: GameId | null,
enabled: readonly GameId[],
): GameId | null {
return focus !== null && enabled.includes(focus) ? focus : null;
}
/**
* The next game in the rotation: all → first → … → last → all.
*
* Working through games one at a time is a loop, and it ends by coming back to
* everything rather than silently starting over — otherwise there is no way out
* of the rotation except finding the "all" chip again.
*/
export function advanceFocus(
focus: GameId | null,
enabled: readonly GameId[],
): GameId | null {
if (enabled.length === 0) return null;
const at = focus === null ? -1 : enabled.indexOf(focus);
// An unknown focus (switched-off game) restarts the rotation rather than
// jumping to index 0 of nowhere.
return enabled[at + 1] ?? null;
}
/** How many rows each game still has outstanding, for the focus chips. */
export function countByGame<T extends Row>(
rows: readonly T[],
): Partial<Record<GameId, number>> {
const out: Partial<Record<GameId, number>> = {};
for (const row of rows) {
out[row.event.game] = (out[row.event.game] ?? 0) + 1;
}
return out;
}
+10
View File
@@ -8,6 +8,15 @@ export interface Prefs {
region: Region; region: Region;
/** 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[];
/**
* One game to look at right now, or null for all of them.
*
* A lens, not a setting: it never changes `hiddenGames`, and a focus on a
* game that is switched off or has left the feed is ignored rather than
* obeyed (`resolveFocus`), so it can never leave the reader on a blank page
* with no visible cause.
*/
focusGame: GameId | null;
/** How the list is ordered. Deadline order is the default and the fallback. */ /** How the list is ordered. Deadline order is the default and the fallback. */
sort: SortMode; sort: SortMode;
/** /**
@@ -29,6 +38,7 @@ function defaults(): Prefs {
return { return {
region: guessRegion(), region: guessRegion(),
hiddenGames: [], hiddenGames: [],
focusGame: null,
sort: "ending", sort: "ending",
detectDaily: true, detectDaily: true,
showCompleted: true, showCompleted: true,
+66 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { firstToExpire, outstanding } from "../src/client/state/lens.ts"; import {
advanceFocus,
countByGame,
firstToExpire,
outstanding,
resolveFocus,
} from "../src/client/state/lens.ts";
import type { GameId } from "../src/shared/schema.ts"; import type { GameId } from "../src/shared/schema.ts";
const row = (id: string, game: GameId, msRemaining: number | null) => ({ const row = (id: string, game: GameId, msRemaining: number | null) => ({
@@ -66,3 +72,62 @@ describe("firstToExpire", () => {
expect(firstToExpire([])).toBeNull(); expect(firstToExpire([])).toBeNull();
}); });
}); });
describe("resolveFocus", () => {
const enabled: GameId[] = ["genshin", "hsr"];
test("a focus on a game they still play stands", () => {
expect(resolveFocus("hsr", enabled)).toBe("hsr");
});
test("a focus on a game they switched off is ignored, not obeyed", () => {
// Obeying it leaves a blank page whose cause is a setting in a panel at the
// bottom. The stored value is left alone, so switching the game back on
// puts them back where they were.
expect(resolveFocus("zzz", enabled)).toBeNull();
});
test("no focus is all games", () => {
expect(resolveFocus(null, enabled)).toBeNull();
});
});
describe("advanceFocus", () => {
const enabled: GameId[] = ["genshin", "hsr", "zzz"];
test("all games leads into the first one", () => {
expect(advanceFocus(null, enabled)).toBe("genshin");
});
test("steps through in order", () => {
expect(advanceFocus("genshin", enabled)).toBe("hsr");
expect(advanceFocus("hsr", enabled)).toBe("zzz");
});
test("the last game leads back out to all of them", () => {
// Not a wrap to the first: a rotation with no exit means the only way back
// to everything is finding the "All" chip, which is the thing the rotation
// was meant to save them.
expect(advanceFocus("zzz", enabled)).toBeNull();
});
test("a focus that is no longer enabled restarts the rotation", () => {
expect(advanceFocus("wuwa", enabled)).toBe("genshin");
});
test("no games to rotate through", () => {
expect(advanceFocus(null, [])).toBeNull();
});
});
describe("countByGame", () => {
test("counts per game and omits games with nothing", () => {
const counts = countByGame([
row("a", "genshin", 1),
row("b", "genshin", 2),
row("c", "hsr", 3),
]);
expect(counts).toEqual({ genshin: 2, hsr: 1 });
expect(counts.zzz).toBeUndefined();
});
});