feat: sort the first-run game picker alphabetically

The picker took `available` in feed order — whichever game happened to hold
the first event row — which means something everywhere else in the app and
nothing on this screen, where the reader is scanning for the two or three
names they already know.

Sorted on the name printed on the button rather than the LaneId, since the id
is not what a reader sees, and through localeCompare rather than `<`, because
a code-point sort files hololive Dreams after every capitalised game instead
of between Genshin and Honkai.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 05:34:00 +02:00
co-authored by Claude Opus 5
parent 24832844a6
commit 7b6ee62235
3 changed files with 71 additions and 5 deletions
+4 -1
View File
@@ -279,7 +279,10 @@ Before any events are shown, the reader picks which games they play, and how the
them. A calendar full of games they
don't play is worse than an empty one — it buries the thing they came for. Nothing is preselected
and the button stays disabled until something is chosen; guessing on their behalf and hoping they
notice is worse than asking. The choice is stored as *hidden* games, the inverse — which is a storage
notice is worse than asking. **The games are listed alphabetically by name**, not in feed order:
everywhere else a lane's position carries meaning, but here the reader is scanning for the two or
three names they already know, and a list ordered by which game happened to hold the first event row
gives them nothing to scan by. The choice is stored as *hidden* games, the inverse — which is a storage
shape, not a policy: what happens to a game added later is decided separately, below.
**A game added later arrives switched off.** Adding a source is our decision, not the reader's, and
+23 -4
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { LaneId } from "../../shared/custom.ts";
import type { View } from "../state/usePrefs.ts";
@@ -37,6 +37,25 @@ export function Welcome({
prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id],
);
/**
* Alphabetical, by the name on the button.
*
* `available` arrives in feed order — whichever game happened to hold the
* first row — which is meaningful everywhere else in the app and meaningless
* here, where the reader is not reading the list but looking for the two or
* three names they already know. Sorted by `name` and not by `LaneId`,
* because the id is not what is printed: `hsr` is Honkai: Star Rail. And
* through `localeCompare`, because `<` orders by code point and would file
* hololive Dreams after every capitalised name on the screen.
*/
const ordered = useMemo(
() =>
[...available].sort((a, b) =>
gameMeta(a).name.localeCompare(gameMeta(b).name),
),
[available, gameMeta],
);
return (
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col px-5 py-12">
<p className="font-display text-[0.9375rem] font-bold tracking-[0.02em]">
@@ -55,7 +74,7 @@ export function Welcome({
pushes the view question and the way in off the bottom of the screen,
on the one screen where both need to be seen. */}
<div className="mt-8 grid gap-2 sm:grid-cols-2">
{available.map((id) => {
{ordered.map((id) => {
const game = gameMeta(id);
const on = chosen.includes(id);
return (
@@ -111,7 +130,7 @@ export function Welcome({
<ViewChoice
value={view}
onChange={setView}
hues={available.slice(0, 3).map((id) => gameMeta(id).hue)}
hues={ordered.slice(0, 3).map((id) => gameMeta(id).hue)}
/>
<div className="mt-8 flex flex-col gap-3">
@@ -127,7 +146,7 @@ export function Welcome({
</button>
<button
type="button"
onClick={() => onConfirm(available, view)}
onClick={() => onConfirm(ordered, view)}
className="text-xs text-faint transition-colors duration-150 hover:text-muted"
>
Show everything instead
+44
View File
@@ -165,8 +165,52 @@ describe("Welcome (first run)", () => {
// layouts they have not seen yet.
expect(html()).toContain('aria-checked="true"');
});
test("lists the games alphabetically by the name on the button", () => {
// Two games here each pin one half of the sort, and neither is decoration:
//
// `nikke` is Goddess of Victory: Nikke, so its id sorts fifth and its name
// third. That is what makes this a test of sorting by *name* rather than by
// `LaneId` — every other set of ids in this table happens to sort into the
// same order as its names, so a comparator on the id passes them all.
//
// `holodori` is hololive Dreams, the one lowercase name in `games.ts`. A
// code-point sort files it after every capitalised game instead of between
// Goddess and Honkai, which is why the comparator has to be `localeCompare`.
//
// The input order is neither feed order nor id order, so nothing lines up
// by luck.
const markup = render(
<Welcome
available={["zzz", "genshin", "holodori", "hsr", "nikke", "arknights"]}
onConfirm={() => {}}
/>,
);
expect(pickerNames(markup)).toEqual([
"Arknights",
"Genshin Impact",
"Goddess of Victory: Nikke",
"hololive Dreams",
"Honkai: Star Rail",
"Zenless Zone Zero",
]);
});
});
/**
* The game names on the picker buttons, in the order they render.
*
* Pinned to the name span's `flex-1` class: edit that className and this
* returns nothing and the assertion fails loudly, which is the safe direction
* to break in. The text arrives HTML-escaped, so a fixture using a name with an
* apostrophe — Girls' Frontline 2: Exilium — must expect `&#x27;` in it.
*/
function pickerNames(markup: string): string[] {
return [...markup.matchAll(/<span class="flex-1[^"]*"[^>]*>([^<]+)<\/span>/g)].map(
(m) => m[1] ?? "",
);
}
describe("Timeline window", () => {
const DAY = 86_400_000;