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
+68 -12
View File
@@ -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() {
</div>
</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" ? (
<>
<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
that expires tonight rather than next patch. */}
<Dailies
games={games.filter((g) => !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 && (
<p className="px-4 py-12 text-sm leading-relaxed text-muted">
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."}
</p>
)}
</>
+3 -3
View File
@@ -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]);
+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({
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({
<section className="border-b border-hairline px-4 py-8">
<p className="eyebrow">Nothing running</p>
<p className="mt-2 max-w-sm text-sm text-muted">
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.`}
</p>
</section>
);
+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,