feat: every list of games follows the reader's order, dailies included

The focus bar, the settings list, the timeline's lanes and the dailies strip all
go through `orderGames` now, resolved once in App. `games` itself stays in feed
order on purpose: `adoptNewLanes` diffs it and `knownGames` is seeded from it,
so ordering it at source would let a display preference reach the code that
decides which of a reader's games get hidden — a reordering bug would become a
game-silently-switched-off bug.

The dailies strip also groups. It was `[...chores, ...repeating]`, which put
Genshin's commissions and Genshin's own login event at opposite ends with a
dozen games between them; a game's chore and its repeating events are now
adjacent, chore first, events in the order they arrived. Collapsed that is
adjacency alone — no per-game headings, because this is the part of the page
answerable in ten seconds and a heading each would make it the tallest block on
it, pushing "next to expire" down the page.

Two things moved to where they belong. Skipping a standing chore for a lane the
reader invented is `dailyGroups`' rule, not the call site's: filtering those
lanes out in App also cost a reader's own game its place in the order its events
group under. And the expanded catch-up panel is its own exported component, so
which days it offers and whose clock they were cut on are testable rather than
trapped behind a `useState`.

`Welcome` drops its own comparator for the shared rule — the picker runs before
any stored order, so it asks for the A–Z case and cannot drift from the four
surfaces behind it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:01:37 +02:00
co-authored by Claude Opus 5
parent bf7959c916
commit 721c4c90fb
6 changed files with 567 additions and 77 deletions
+28 -10
View File
@@ -30,10 +30,10 @@ import {
} from "./state/lens.ts";
import { clockFor, formatRemaining } from "../shared/time.ts";
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
import { metaOnTheme, useTheme } from "./state/theme.ts";
import {
isCustomGameId,
type CustomEvents,
type CustomGames,
type LaneId,
@@ -238,10 +238,24 @@ export function App() {
if (patch !== null) update(patch);
}, [state.status, games, prefs.knownGames, prefs.hiddenGames, update]);
/** Games the reader plays, in feed order. The focus bar rotates through these. */
/**
* Every lane in the order the reader reads them in.
*
* `games` above stays the lane-*identity* list: `adoptNewLanes` diffs it to
* decide which games arrive switched off and `knownGames` is seeded from it,
* so reordering it at source would let a display preference reach the logic
* that hides a reader's games. Ordering is applied here instead, once, and
* handed to every surface that shows a game.
*/
const ordered = useMemo(
() => orderGames(games, prefs.gameOrder, (id) => gameMeta(id).name),
[games, prefs.gameOrder, gameMeta],
);
/** Games the reader plays, in their order. The focus bar rotates through these. */
const enabled = useMemo(
() => games.filter((g) => !prefs.hiddenGames.includes(g)),
[games, prefs.hiddenGames],
() => ordered.filter((g) => !prefs.hiddenGames.includes(g)),
[ordered, prefs.hiddenGames],
);
// A focus on a game they have since switched off is ignored, not obeyed —
@@ -359,7 +373,7 @@ export function App() {
<GameMetaProvider value={gameMeta}>
<Shell>
<Welcome
available={games}
available={ordered}
onConfirm={(chosen, chosenView) =>
update({
onboarded: true,
@@ -477,11 +491,12 @@ export function App() {
that expires tonight rather than next patch. */}
{/* Standing chores are a tracked-game notion: there is no routine we
could name on behalf of a game the reader invented, so their lanes
contribute repeating events here but no chore of their own. */}
contribute repeating events here but no chore of their own. That
exclusion is `dailyGroups`' to make, not this call site's — filtering
the lanes out here would also cost a reader's own game its place in
the order their repeating events are grouped under. */}
<Dailies
games={(focus === null ? enabled : [focus]).filter(
(id) => !isCustomGameId(id),
)}
games={focus === null ? enabled : [focus]}
events={todo.filter(repeatsDaily).map((r) => r.event)}
region={prefs.region}
now={now}
@@ -571,6 +586,9 @@ export function App() {
// nothing else left to draw. The switch is in settings.
showUpcoming={prefs.showUpcoming}
splitUpcoming={prefs.timelineSplitUpcoming}
// Lanes stack in the reader's game order, so their main game is the
// top lane rather than whichever one held the first row.
gameOrder={ordered}
onOpen={setOpenId}
isDone={isDone}
/>
@@ -578,7 +596,7 @@ export function App() {
)}
<Controls
games={games}
games={ordered}
prefs={prefs}
onToggleGame={toggleGame}
onUpdate={update}