feat: decide the order games are listed in

Nothing did. Every surface that lists a game rendered App's `games`, which is
the order lanes first appear in the feed — whichever game happened to hold the
first event row. It is arbitrary, it moves as events come and go, and it was
the same complaint the first-run picker had.

So `orderGames` is one rule in one place, and `prefs.gameOrder` is where the
reader's own answer goes. Absent means they have never placed a game rather
than an empty order, which is the distinction `knownGames` already draws and
the same trap: every install predating the field is in that state, and reading
it the other way would hand them a blank list. They get A–Z by the name on
screen instead.

Two properties carry the weight, and both are tested rather than asserted. The
result is always a permutation of the lanes given, because a game dropped
here looks exactly like a game switched off and switching it on would not
bring it back. And a lane the order does not name trails the ones it does, so
a game we add later never lands in the middle of a hand-made order and a
retired source keeps its slot for when it returns.

Nothing reads it yet.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:00:32 +02:00
co-authored by Claude Opus 5
parent 7b6ee62235
commit 88d5ea2e43
3 changed files with 290 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import type { LaneId } from "../../shared/custom.ts";
/**
* The order games appear in — the reader's, when they have given one.
*
* Nothing used to decide this. The focus bar, the settings chips and the
* timeline's lanes all render `App`'s `games`, which is the order lanes first
* appear in the feed — whichever game happened to hold the first event row. It
* is arbitrary, it shifts as events come and go, and it is the same problem the
* first-run picker had (`docs/PRD.md` F8).
*
* Pure and its own module for the reason `lens.ts`, `zoom.ts` and `lanes.ts` are:
* `prefs` stores the answer, four surfaces read it, and a rule that lives in one
* place cannot drift between them.
*/
/**
* Put lanes in the reader's order, falling back to alphabetical.
*
* `stored` absent means the reader has never placed a game — not that they have
* no order. Every install predating this is in that state, which is the same
* distinction `knownGames` draws in `usePrefs.ts`, and the fallback is what the
* first-run picker already does.
*
* The result is **always a permutation of `lanes`**: never a lane dropped, never
* one invented, and never a duplicate even if `stored` carries one. That is the
* property worth testing rather than the ordering itself — a game missing from
* the focus bar or from settings looks exactly like a game the reader switched
* off, and their fix for that, switching it back on, would do nothing at all.
*
* Sorted on the name the reader sees and not on the `LaneId`, because the id is
* not what is printed: `hsr` is Honkai: Star Rail and `nikke` is Goddess of
* Victory: Nikke. Through `localeCompare`, because `<` orders by code point and
* files hololive Dreams after every capitalised game in `games.ts`.
*/
export function orderGames(
lanes: readonly LaneId[],
stored: readonly LaneId[] | undefined,
nameOf: (id: LaneId) => string,
): LaneId[] {
const alphabetical = (ids: readonly LaneId[]): LaneId[] =>
[...ids].sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
if (stored === undefined) return alphabetical(lanes);
const present = new Set(lanes);
const placed: LaneId[] = [];
const seen = new Set<LaneId>();
for (const id of stored) {
// A stored id naming a lane that is not here is skipped rather than
// rendered as a gap — and left in `stored`, so a source that goes away and
// comes back returns to the slot the reader chose. Nothing prunes a stored
// order against the feed, for the reason nothing else in the client prunes
// against it either (AGENTS.md § Retiring a game).
if (!present.has(id) || seen.has(id)) continue;
seen.add(id);
placed.push(id);
}
// A game we added later is not entitled to a position in an order the reader
// made by hand, so it trails what they placed rather than slotting into the
// middle of it. It also arrives switched off (`adoptNewLanes`), so settings —
// where they would move it anyway — is where they meet it.
return [...placed, ...alphabetical(lanes.filter((id) => !seen.has(id)))];
}
/**
* Move one entry, shifting the rest.
*
* Applied to the list **as displayed**, and the whole result is what gets
* stored. Both halves of that matter. The indices a drag or an arrow produces
* are positions on screen, so applying them to a stored order that names only
* some of the lanes would move the wrong game; and storing only the game that
* moved would leave `orderGames` reading "that one, then everything else
* alphabetically", which is not what dragging one row one notch means. Writing
* back what the reader is looking at gets both right at once.
*
* An index off either end is a no-op rather than an error, which is what makes
* the first row's ↑ and the last row's ↓ harmless to press — they are rendered
* either way, because a control that vanishes at the ends moves the other one
* under the reader's finger.
*/
export function moveGame(
order: readonly LaneId[],
from: number,
to: number,
): LaneId[] {
const next = [...order];
if (from < 0 || from >= next.length || to < 0 || to >= next.length) return next;
const [moved] = next.splice(from, 1);
if (moved === undefined) return [...order];
next.splice(to, 0, moved);
return next;
}