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:
co-authored by
Claude Opus 5
parent
bf7959c916
commit
721c4c90fb
@@ -8,10 +8,12 @@ import {
|
||||
startMarkers,
|
||||
Timeline,
|
||||
} from "../src/client/components/Timeline.tsx";
|
||||
import { CatchUpPanel, dailyGroups } from "../src/client/components/Dailies.tsx";
|
||||
import { Welcome } from "../src/client/components/Welcome.tsx";
|
||||
import { timelineLanes } from "../src/client/state/lanes.ts";
|
||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||
import { metaFor } from "../src/shared/games.ts";
|
||||
import { dayKey } from "../src/shared/daily.ts";
|
||||
import { clockFor } from "../src/shared/time.ts";
|
||||
import { GachaEvent, type GameId } from "../src/shared/schema.ts";
|
||||
|
||||
@@ -262,6 +264,39 @@ describe("timelineLanes", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("the reader's game order stacks the lanes", () => {
|
||||
const lanes = timelineLanes(rows, "game", true, ["zzz", "hsr", "genshin"]);
|
||||
expect(lanes.map((l) => l.game)).toEqual(["zzz", "hsr", "genshin"]);
|
||||
});
|
||||
|
||||
test("ordering the lanes does not re-sort the rows inside one", () => {
|
||||
// The same rule the mode itself follows, read one level up.
|
||||
const lanes = timelineLanes(rows, "game", true, ["genshin"]);
|
||||
expect(lanes[0]?.rows.map((r) => r.event.title)).toEqual([
|
||||
"Closing Ceremony",
|
||||
"Third Rail",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a game the order does not name keeps its place behind the ones it does", () => {
|
||||
// Total for a lane the reader never placed — and stable, so the unplaced
|
||||
// games stay in the order their rows arrived rather than shuffling.
|
||||
const lanes = timelineLanes(rows, "game", true, ["zzz"]);
|
||||
expect(lanes.map((l) => l.game)).toEqual(["zzz", "genshin", "hsr"]);
|
||||
});
|
||||
|
||||
test("no order given stacks exactly as it did before", () => {
|
||||
expect(timelineLanes(rows, "game", true).map((l) => l.game)).toEqual(
|
||||
timelineLanes(rows, "game", true, undefined).map((l) => l.game),
|
||||
);
|
||||
});
|
||||
|
||||
test("the merged stack ignores it, having one lane and no game", () => {
|
||||
const lanes = timelineLanes(rows, "ending", true, ["zzz", "hsr", "genshin"]);
|
||||
expect(lanes).toHaveLength(1);
|
||||
expect(lanes[0]?.game).toBeNull();
|
||||
});
|
||||
|
||||
test("ending soonest: every game in one stack, deadline order", () => {
|
||||
const lanes = timelineLanes(rows, "ending");
|
||||
expect(lanes).toHaveLength(1);
|
||||
@@ -569,3 +604,220 @@ describe("markerLabel", () => {
|
||||
expect(label).toContain("–");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyGroups", () => {
|
||||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||||
const meta = (id: string) => metaFor(id, {});
|
||||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||||
|
||||
const repeating = (id: string, game: string, title: string) =>
|
||||
({
|
||||
id,
|
||||
game,
|
||||
title,
|
||||
type: "login",
|
||||
summary: null,
|
||||
startsAt: "2026-08-10T00:00:00.000Z",
|
||||
startPrecision: "day",
|
||||
endsAt: null,
|
||||
endPrecision: "unknown",
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl: "https://example.test",
|
||||
}) as never;
|
||||
|
||||
test("a game's chore and its events are adjacent, chore first", () => {
|
||||
// This is the whole point: the strip used to list every chore and then
|
||||
// every event, so Genshin's commissions and Genshin's login event sat at
|
||||
// opposite ends with a dozen games between them.
|
||||
const groups = dailyGroups(
|
||||
["genshin", "hsr"],
|
||||
[repeating("e1", "genshin", "Login Bonus"), repeating("e2", "hsr", "Sign In")],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
);
|
||||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([
|
||||
["dailies:genshin", "e1"],
|
||||
["dailies:hsr", "e2"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("groups follow the order the games arrive in", () => {
|
||||
// The reader's order, resolved upstream by `orderGames`.
|
||||
const groups = dailyGroups(["hsr", "genshin"], [], NOW, "europe", meta, startOf);
|
||||
expect(groups.map((g) => g.game)).toEqual(["hsr", "genshin"]);
|
||||
});
|
||||
|
||||
test("events keep their given order inside a game", () => {
|
||||
// Grouping is not a licence to re-sort within a group.
|
||||
const groups = dailyGroups(
|
||||
["genshin"],
|
||||
[
|
||||
repeating("late", "genshin", "Second"),
|
||||
repeating("early", "genshin", "First"),
|
||||
],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
);
|
||||
expect(groups[0]?.items.map((i) => i.key)).toEqual([
|
||||
"dailies:genshin",
|
||||
"late",
|
||||
"early",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a lane the reader invented gets no standing chore", () => {
|
||||
// There is no routine we could name on their behalf — but their repeating
|
||||
// events still group under it.
|
||||
const groups = dailyGroups(
|
||||
["mygame:mine"],
|
||||
[repeating("m1", "mygame:mine", "My Daily")],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
);
|
||||
expect(groups[0]?.items.map((i) => i.key)).toEqual(["m1"]);
|
||||
});
|
||||
|
||||
test("a lane with nothing to tick is dropped, not rendered as an empty heading", () => {
|
||||
const groups = dailyGroups(["mygame:empty"], [], NOW, "europe", meta, startOf);
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
||||
test("an event whose lane is not listed still gets a line", () => {
|
||||
// Dropping it would quietly remove something tickable. It trails the lanes
|
||||
// that were listed.
|
||||
const groups = dailyGroups(
|
||||
["genshin"],
|
||||
[repeating("stray", "zzz", "Stray Daily")],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
);
|
||||
expect(groups.map((g) => g.game)).toEqual(["genshin", "zzz"]);
|
||||
});
|
||||
|
||||
test("a done event contributes nothing, because it never arrives here", () => {
|
||||
// The strip is an instruction, not a record: App filters completed and
|
||||
// ignored events out before this sees them (`outstanding` in lens.ts), so
|
||||
// catch-up cannot resurrect a chip for something the reader finished.
|
||||
const groups = dailyGroups(["genshin"], [], NOW, "europe", meta, startOf);
|
||||
expect(groups[0]?.items.map((i) => i.key)).toEqual(["dailies:genshin"]);
|
||||
});
|
||||
|
||||
test("a chore has no start to clip a catch-up strip at; an event does", () => {
|
||||
const groups = dailyGroups(
|
||||
["genshin"],
|
||||
[repeating("e1", "genshin", "Login Bonus")],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
);
|
||||
const [chore, event] = groups[0]?.items ?? [];
|
||||
expect(chore?.notBefore).toBeNull();
|
||||
expect(event?.notBefore).toBe(Date.parse("2026-08-10T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
test("each item carries its own game's reset clock", () => {
|
||||
// Endfield serves Europe off the Americas machine, so its day rolls at
|
||||
// 09:00 UTC. A section-wide "today" would tick the wrong box for hours.
|
||||
const dawn = Date.parse("2026-08-17T05:00:00.000Z");
|
||||
const groups = dailyGroups(
|
||||
["genshin", "endfield"],
|
||||
[],
|
||||
dawn,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
);
|
||||
const today = groups.map((g) => g.items[0]?.today);
|
||||
expect(today[0]).not.toBe(today[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CatchUpPanel", () => {
|
||||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||||
const meta = (id: string) => metaFor(id, {});
|
||||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||||
|
||||
const groups = () =>
|
||||
dailyGroups(["genshin"], [], NOW, "europe", meta, startOf);
|
||||
|
||||
const panel = (logged: string[] = []) =>
|
||||
render(
|
||||
<CatchUpPanel
|
||||
groups={groups()}
|
||||
now={NOW}
|
||||
region="europe"
|
||||
daysFor={() => logged}
|
||||
onToggleDay={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
test("a fortnight of days, each one tickable", () => {
|
||||
const markup = panel();
|
||||
const pips = [...markup.matchAll(/aria-label="[^"]*(?:not )?done"/g)];
|
||||
expect(pips).toHaveLength(14);
|
||||
});
|
||||
|
||||
test("no day later than today, because nobody can have done tomorrow", () => {
|
||||
// The pip for a future day would be a control for a claim that cannot be
|
||||
// true. It is absent rather than present-and-disabled.
|
||||
expect(panel()).not.toContain('disabled=""');
|
||||
});
|
||||
|
||||
test("a logged day reads as done, an unlogged one does not", () => {
|
||||
const markup = panel([dayKey(NOW, "europe", "genshin")]);
|
||||
expect(markup).toContain(", done\"");
|
||||
expect(markup).toContain(", not done\"");
|
||||
});
|
||||
|
||||
test("names the game, so an expanded strip says whose days these are", () => {
|
||||
expect(panel()).toContain("Genshin Impact");
|
||||
});
|
||||
|
||||
test("an undated event's strip starts at the event, not a fortnight ago", () => {
|
||||
const started = NOW - 2 * 86_400_000;
|
||||
const withEvent = dailyGroups(
|
||||
["genshin"],
|
||||
[
|
||||
{
|
||||
id: "e1",
|
||||
game: "genshin",
|
||||
title: "Login Bonus",
|
||||
type: "login",
|
||||
summary: null,
|
||||
startsAt: new Date(started).toISOString(),
|
||||
startPrecision: "day",
|
||||
endsAt: null,
|
||||
endPrecision: "unknown",
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl: "https://example.test",
|
||||
} as never,
|
||||
],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
);
|
||||
const markup = render(
|
||||
<CatchUpPanel
|
||||
groups={withEvent}
|
||||
now={NOW}
|
||||
region="europe"
|
||||
daysFor={() => []}
|
||||
onToggleDay={() => {}}
|
||||
/>,
|
||||
);
|
||||
// The chore's fourteen, plus three for an event that opened two days ago.
|
||||
expect([...markup.matchAll(/aria-label="[^"]*(?:not )?done"/g)]).toHaveLength(17);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user