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;
}
+22
View File
@@ -47,6 +47,28 @@ export interface Prefs {
* them in.
*/
knownGames?: LaneId[];
/**
* The order the reader put their games in.
*
* Absent means they have never placed one — not that they have no order, and
* not an empty order. Every install predating this is in that state, so the
* fallback has to be a rule rather than a stored value: `orderGames` sorts
* alphabetically by name, which is what the first-run picker already does.
* A game added later trails everything they placed by hand.
*
* `| undefined` is explicit because `exactOptionalPropertyTypes` is on and
* "Reset to AZ" is a patch that writes the field away — `{ gameOrder:
* undefined }` has to be assignable for the reset to typecheck, and
* `JSON.stringify` drops it on the way to storage.
*
* Not a key space and not a migration: one more field in the single `prefs`
* blob, like `timelineGroup`. It rides the export for free, though note that
* `importProgress` restores progress, dailies, ignores and the reader's own
* games — never prefs — so an imported file does not bring an order back.
* That asymmetry predates this field and applies to region, theme and view
* alike.
*/
gameOrder?: LaneId[] | undefined;
/**
* One game to look at right now, or null for all of them.
*
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, test } from "bun:test";
import { moveGame, orderGames } from "../src/client/state/gameOrder.ts";
import { metaFor } from "../src/shared/games.ts";
import type { LaneId } from "../src/shared/custom.ts";
/**
* The reader's game order.
*
* The property that matters most here is not the order itself but that this
* can never lose a lane: a game missing from the focus bar or from settings
* looks exactly like a game that has been switched off, and the reader's fix
* for that — go and switch it back on — does nothing.
*/
const nameOf = (id: LaneId) => metaFor(id, {}).name;
/** Deliberately neither alphabetical by name nor sorted by id. */
const LANES: LaneId[] = ["zzz", "genshin", "holodori", "hsr", "nikke", "arknights"];
const BY_NAME = [
"arknights", // Arknights
"genshin", // Genshin Impact
"nikke", // Goddess of Victory: Nikke
"holodori", // hololive Dreams
"hsr", // Honkai: Star Rail
"zzz", // Zenless Zone Zero
];
describe("orderGames", () => {
test("no stored order: alphabetical by the name the reader sees", () => {
// Not by LaneId — `hsr` is Honkai: Star Rail, and `nikke` is Goddess of
// Victory: Nikke, which sorts fifth by id and third by name.
expect(orderGames(LANES, undefined, nameOf)).toEqual(BY_NAME);
});
test("no stored order: a lowercase name sorts by letter, not by code point", () => {
// hololive Dreams is the one lowercase name in `games.ts`. A `<` comparison
// files it after every capitalised game instead of between Goddess and
// Honkai.
const order = orderGames(["hsr", "holodori", "nikke"], undefined, nameOf);
expect(order).toEqual(["nikke", "holodori", "hsr"]);
});
test("a stored order is obeyed exactly", () => {
const stored = ["zzz", "hsr", "genshin"];
expect(orderGames(["genshin", "hsr", "zzz"], stored, nameOf)).toEqual(stored);
});
test("lanes the reader never placed come after the ones they did, alphabetically", () => {
// The reader ordered two games; four more exist. Their two stay put and the
// rest arrive in name order behind them — a game we added is not entitled
// to a position in an order the reader made.
const order = orderGames(LANES, ["zzz", "hsr"], nameOf);
expect(order).toEqual(["zzz", "hsr", "arknights", "genshin", "nikke", "holodori"]);
});
test("a game that has left the feed is skipped, not rendered as a gap", () => {
const order = orderGames(["genshin", "hsr"], ["zzz", "hsr", "genshin"], nameOf);
expect(order).toEqual(["hsr", "genshin"]);
});
test("a retired game keeps its slot for when it comes back", () => {
// We filter on output rather than pruning the stored list, so a source that
// goes away and returns does not cost the reader the position they chose.
const stored = ["zzz", "hsr", "genshin"];
const gone = orderGames(["hsr", "genshin"], stored, nameOf);
expect(gone).toEqual(["hsr", "genshin"]);
expect(orderGames(["genshin", "hsr", "zzz"], stored, nameOf)).toEqual(stored);
});
test("the reader's own lanes sort with everything else", () => {
const meta = (id: LaneId) =>
metaFor(id, {
"mygame:aether": {
id: "mygame:aether",
name: "Aether Gazer",
hue: "#888888",
at: "2026-08-01T00:00:00.000Z",
},
}).name;
expect(orderGames(["hsr", "mygame:aether"], undefined, meta)).toEqual([
"mygame:aether",
"hsr",
]);
});
test("a lane with no metadata still sorts instead of throwing", () => {
// `metaFor` is total and answers "Unknown game" for a lane it does not
// know, which is what an import carrying an event whose game did not come
// with it looks like.
const order = orderGames(["zzz", "mygame:ghost"], undefined, nameOf);
expect(order).toHaveLength(2);
expect(new Set(order)).toEqual(new Set(["zzz", "mygame:ghost"]));
});
test("always a permutation of the lanes it was given", () => {
// The safety property: a game dropped here is indistinguishable, on screen,
// from a game the reader switched off — and switching it on would not bring
// it back.
const cases: Array<LaneId[] | undefined> = [
undefined,
[],
["zzz"],
["nope", "zzz", "hsr"],
[...LANES].reverse(),
["zzz", "zzz", "hsr"],
];
for (const stored of cases) {
const order = orderGames(LANES, stored, nameOf);
expect([...order].sort()).toEqual([...LANES].sort());
}
});
test("an empty lane list is not an error", () => {
expect(orderGames([], ["zzz"], nameOf)).toEqual([]);
expect(orderGames([], undefined, nameOf)).toEqual([]);
});
test("does not mutate what it is given", () => {
const lanes = [...LANES];
const stored = ["zzz", "hsr"];
orderGames(lanes, stored, nameOf);
expect(lanes).toEqual(LANES);
expect(stored).toEqual(["zzz", "hsr"]);
});
});
describe("moveGame", () => {
test("a move records the whole displayed list, so a partial stored order cannot mis-map", () => {
// The indices come from what is on screen. Applied to the displayed list,
// the result names every lane — which is what gets stored, so the next read
// needs no fallback for the games the reader never touched.
const displayed = ["a", "b", "c", "d"];
expect(moveGame(displayed, 3, 0)).toEqual(["d", "a", "b", "c"]);
expect(moveGame(displayed, 3, 0)).toHaveLength(displayed.length);
});
test("moves one game and shifts the rest", () => {
expect(moveGame(["a", "b", "c", "d"], 2, 0)).toEqual(["c", "a", "b", "d"]);
expect(moveGame(["a", "b", "c", "d"], 0, 3)).toEqual(["b", "c", "d", "a"]);
});
test("a one-step move is the arrow buttons", () => {
expect(moveGame(["a", "b", "c"], 1, 0)).toEqual(["b", "a", "c"]);
expect(moveGame(["a", "b", "c"], 1, 2)).toEqual(["a", "c", "b"]);
});
test("off either end is a no-op, so the first row's up arrow is harmless", () => {
const order = ["a", "b", "c"];
expect(moveGame(order, 0, -1)).toEqual(order);
expect(moveGame(order, 2, 3)).toEqual(order);
expect(moveGame(order, -1, 1)).toEqual(order);
expect(moveGame(order, 9, 1)).toEqual(order);
});
test("moving a game onto itself changes nothing", () => {
expect(moveGame(["a", "b", "c"], 1, 1)).toEqual(["a", "b", "c"]);
});
test("never loses or duplicates a game", () => {
const order = ["a", "b", "c", "d", "e"];
for (let from = 0; from < order.length; from += 1) {
for (let to = 0; to < order.length; to += 1) {
expect([...moveGame(order, from, to)].sort()).toEqual([...order].sort());
}
}
});
test("does not mutate what it is given", () => {
const order = ["a", "b", "c"];
moveGame(order, 0, 2);
expect(order).toEqual(["a", "b", "c"]);
});
});