From 88d5ea2e436e895b3b0f7449d7ced939fd98c13d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 20 Aug 2026 06:00:32 +0200 Subject: [PATCH] feat: decide the order games are listed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/state/gameOrder.ts | 94 ++++++++++++++++++ src/client/state/usePrefs.ts | 22 +++++ test/game-order.test.ts | 174 ++++++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 src/client/state/gameOrder.ts create mode 100644 test/game-order.test.ts diff --git a/src/client/state/gameOrder.ts b/src/client/state/gameOrder.ts new file mode 100644 index 0000000..32ba688 --- /dev/null +++ b/src/client/state/gameOrder.ts @@ -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(); + 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; +} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 6db629d..5bcd63b 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -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 A–Z" 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. * diff --git a/test/game-order.test.ts b/test/game-order.test.ts new file mode 100644 index 0000000..bbed940 --- /dev/null +++ b/test/game-order.test.ts @@ -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 = [ + 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"]); + }); +});