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
+24 -1
View File
@@ -74,11 +74,21 @@ export interface Lane<T> {
* modes, lane mode included: leaving a lane's given order alone there would
* produce the block it was told not to draw, minus the heading that explained
* it, which is the worst of both answers.
*
* `gameOrder` stacks the lanes themselves — the reader's own game order, so
* their main game is the top lane instead of whichever one held the first row.
* It orders **lanes and never the rows inside one**, which is the same rule as
* above read one level up. A game the order does not name sorts after the ones
* it does, in the order its rows arrived, so this stays total for a lane the
* reader never placed. Omitted leaves the stacking exactly as it was.
*
* The merged mode ignores it, having one lane and no game to order by.
*/
export function timelineLanes<T extends Row>(
rows: readonly T[],
mode: TimelineGroup,
split = true,
gameOrder?: readonly LaneId[],
): Array<Lane<T>> {
const order = split ? endingSoonestFirst : byDeadline;
@@ -91,7 +101,20 @@ export function timelineLanes<T extends Row>(
for (const row of rows) {
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
}
return [...byGame].map(([game, laneRows]) => ({
let lanes = [...byGame];
if (gameOrder !== undefined) {
// Unplaced lanes take a rank past every placed one, and ties keep their
// arrival order — `sort` is stable, so a game the reader never placed does
// not jump the ones they did.
const rank = (game: LaneId) => {
const at = gameOrder.indexOf(game);
return at === -1 ? gameOrder.length : at;
};
lanes = lanes.sort(([a], [b]) => rank(a) - rank(b));
}
return lanes.map(([game, laneRows]) => ({
id: game,
game,
rows: split ? laneRows : [...laneRows].sort(byDeadline),