From d70720e2aff3c73f135841095924dd48cf66d8bf Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 16 Aug 2026 20:42:08 +0200 Subject: [PATCH] feat(filter): focus one game at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 3 +- docs/ARCHITECTURE.md | 7 +- docs/DATA-MODEL.md | 4 +- docs/PRD.md | 14 +++ src/client/App.tsx | 80 ++++++++++++++--- src/client/components/Dailies.tsx | 6 +- src/client/components/GameFocus.tsx | 132 ++++++++++++++++++++++++++++ src/client/components/NextUp.tsx | 8 +- src/client/state/lens.ts | 43 +++++++++ src/client/state/usePrefs.ts | 10 +++ test/lens.test.ts | 67 +++++++++++++- 11 files changed, 350 insertions(+), 24 deletions(-) create mode 100644 src/client/components/GameFocus.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 5daf8f4..0dc37c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 src/client/ React app, service worker, manifest 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) serve.ts static server + /api/health -test/ 309 tests +test/ 318 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever snapshots/ current page per source, rewritten by refresh — see its README ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 27a2962..0b47f7d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -88,9 +88,10 @@ src/ Timeline.tsx calendar lanes (F1) EventDetail.tsx detail sheet, ignore action (F9) 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 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) Welcome.tsx first-run game picker (F8) Toast.tsx undo an ignore @@ -100,9 +101,9 @@ src/ useMarkSet.ts ignores (and the superseded completions shape) useProgress.ts status, effort, note, daily override (F12) 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 - 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 scripts/ build-feed.ts fixtures → public/data/events.v1.json ✓ built diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index dbb2c24..ecfd0fe 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -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:daily" // { [id]: { days: ["2026-08-15", ...], at } } "gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this" -"gacha-tracker:v1:prefs" // { region, hiddenGames[], sort, detectDaily, showCompleted, - // showIgnored, regionConfirmed, onboarded } +"gacha-tracker:v1:prefs" // { region, hiddenGames[], focusGame, sort, detectDaily, + // showCompleted, showIgnored, regionConfirmed, onboarded } "gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written ``` diff --git a/docs/PRD.md b/docs/PRD.md index 60cc421..e7f4365 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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. 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.** 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 diff --git a/src/client/App.tsx b/src/client/App.tsx index ee1c10f..f2f51c6 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { fetchFeed, type FeedState } from "./api.ts"; import { Controls } from "./components/Controls.tsx"; import { Dailies } from "./components/Dailies.tsx"; +import { GameFocus } from "./components/GameFocus.tsx"; import { EventDetail } from "./components/EventDetail.tsx"; import { EventRow, type DailyBadge, type RowEvent } from "./components/EventRow.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 { usePrefs } from "./state/usePrefs.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 { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts"; +import { gameMeta } from "../shared/games.ts"; import type { GameId } from "../shared/schema.ts"; type View = "soon" | "calendar"; @@ -148,7 +156,24 @@ export function App() { [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 .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 // — that is the whole point of ignoring one. .filter((r) => prefs.showIgnored || !isIgnored(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)), + .filter((r) => prefs.showCompleted || !isDone(r.event.id)), [ allRows, prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, - prefs.sort, prog.progress, - daily.logs, 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 upcoming = visible.filter((r) => r.clock.upcoming); @@ -188,6 +218,14 @@ export function App() { const todo = outstanding(live, isDone, isIgnored); 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; if (state.status === "loading") { @@ -274,14 +312,31 @@ export function App() { + {/* 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. */} + update({ focusGame })} + onAdvance={() => update({ focusGame: advanceFocus(focus, enabled) })} + /> + {view === "soon" ? ( <> - + {/* The chores no wiki publishes, and the only thing on this page that expires tonight rather than next patch. */} !prefs.hiddenGames.includes(g))} + games={focus === null ? enabled : [focus]} events={todo.filter(repeatsDaily).map((r) => r.event)} region={prefs.region} now={now} @@ -357,8 +412,9 @@ export function App() { {visible.length === 0 && (

- Nothing to show. Every game is switched off, or you've finished - everything and hidden completed events. + {focus !== null + ? `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."}

)} diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index 6b56161..75af510 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -73,9 +73,9 @@ export function Dailies({ previous.current = { total, complete }; if (was === null || !allDone) return; // 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 - // "all complete" without them having ticked anything — a burst there is the - // app congratulating them for filtering. + // shorter when the reader focuses a single game or marks a repeating event + // done, which can land on "all complete" without them having ticked + // anything — a burst there is the app congratulating them for filtering. if (was.total === total && complete > was.complete) setBurst((n) => n + 1); }, [total, complete, allDone]); diff --git a/src/client/components/GameFocus.tsx b/src/client/components/GameFocus.tsx new file mode 100644 index 0000000..cba2eb9 --- /dev/null +++ b/src/client/components/GameFocus.tsx @@ -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>; + 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 ( +
+
+

Focus

+ +
+ +
+ onFocus(null)} + /> + {games.map((id) => { + const game = gameMeta(id); + return ( + onFocus(focus === id ? null : id)} + /> + ); + })} +
+
+ ); +} + +/** + * 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 ( + + ); +} diff --git a/src/client/components/NextUp.tsx b/src/client/components/NextUp.tsx index 5f0da84..78f4131 100644 --- a/src/client/components/NextUp.tsx +++ b/src/client/components/NextUp.tsx @@ -12,6 +12,7 @@ import { Meter, URGENCY_COLOR } from "./Meter.tsx"; */ export function NextUp({ row, + focused, onOpen, }: { /** @@ -21,6 +22,8 @@ export function NextUp({ * they have chosen to keep it elsewhere. */ row: RowEvent | null; + /** Name of the game being focused on, when the page is narrowed to one. */ + focused: string | null; onOpen: (id: string) => void; }) { if (row === null) { @@ -28,8 +31,9 @@ export function NextUp({

Nothing running

- Nothing live and unfinished in the games you have switched on. Turn a - game back on below, or check again after the next patch. + {focused === null + ? "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.`}

); diff --git a/src/client/state/lens.ts b/src/client/state/lens.ts index f691569..47c19d7 100644 --- a/src/client/state/lens.ts +++ b/src/client/state/lens.ts @@ -62,3 +62,46 @@ export function firstToExpire(rows: readonly T[]): T | 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( + rows: readonly T[], +): Partial> { + const out: Partial> = {}; + for (const row of rows) { + out[row.event.game] = (out[row.event.game] ?? 0) + 1; + } + return out; +} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index c2df188..3f0cf99 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -8,6 +8,15 @@ export interface Prefs { region: Region; /** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */ 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. */ sort: SortMode; /** @@ -29,6 +38,7 @@ function defaults(): Prefs { return { region: guessRegion(), hiddenGames: [], + focusGame: null, sort: "ending", detectDaily: true, showCompleted: true, diff --git a/test/lens.test.ts b/test/lens.test.ts index de8e9f2..dd17c21 100644 --- a/test/lens.test.ts +++ b/test/lens.test.ts @@ -1,5 +1,11 @@ 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"; const row = (id: string, game: GameId, msRemaining: number | null) => ({ @@ -66,3 +72,62 @@ describe("firstToExpire", () => { 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(); + }); +});