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:
co-authored by
Claude Opus 5
parent
2a7a117705
commit
d70720e2af
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user