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
+43
View File
@@ -62,3 +62,46 @@ export function firstToExpire<T extends Row>(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<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;
/** 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,