diff --git a/src/client/state/lanes.ts b/src/client/state/lanes.ts new file mode 100644 index 0000000..13435f5 --- /dev/null +++ b/src/client/state/lanes.ts @@ -0,0 +1,83 @@ +import type { LaneId } from "../../shared/custom.ts"; +import { endingSoonestFirst, type EventClock } from "../../shared/time.ts"; + +/** + * How the timeline is stacked: a lane per game, or every game together in + * deadline order. + * + * Two different questions, and one board cannot answer both. Lanes answer "how + * does this game's patch lay out?" — they keep a game's events adjacent and + * comparable, which is what makes the board readable for someone playing four + * of them. But a reader with four games also has one queue of deadlines, and + * lanes scatter it: the thing ending tonight sits three lanes below the thing + * ending next month, and no amount of scrolling puts them next to each other. + * + * Pure, and its own module rather than logic inside `Timeline`, because `prefs` + * stores the chosen mode and the two must agree on what is valid — the same + * reason `zoom.ts` exists. + */ +export type TimelineGroup = "game" | "ending"; + +export const TIMELINE_GROUPS: Array<{ + id: TimelineGroup; + label: string; + hint: string; +}> = [ + { id: "game", label: "By game", hint: "One lane per game" }, + { + id: "ending", + label: "Ending soonest", + hint: "Every game together, in deadline order", + }, +]; + +/** The shape this module needs. Structural, so it stays cheap to call. */ +interface Row { + event: { game: LaneId }; + clock: EventClock; +} + +/** + * One stack of bars on the board. + * + * `game` is null on the merged board, which is what tells the renderer to drop + * the lane heading and name the game on each bar instead: the colour alone + * cannot say which game an event belongs to once thirteen of them share a + * stack. + */ +export interface Lane { + /** React key and lane identity — the game id, or `all` when merged. */ + id: string; + game: LaneId | null; + rows: T[]; +} + +/** + * Stack the board's rows the way the reader asked for. + * + * The merged mode sorts with `endingSoonestFirst`, the same comparator the + * list's "Ending soonest" uses, rather than a bare end-date sort — otherwise + * the two views would mean different things by the same words, and an event + * that has not started yet would cut in above one that is running out tonight. + * It also carries the `endsAt: null` rule for free: an unannounced end sorts + * behind every dated one instead of pretending to a position in the queue. + * + * Lane mode leaves the order it was given alone. The rows arrive sorted by + * whatever the reader chose in the list, and grouping them by game is not a + * licence to re-sort inside a game. + */ +export function timelineLanes( + rows: readonly T[], + mode: TimelineGroup, +): Array> { + if (mode === "ending") { + if (rows.length === 0) return []; + return [{ id: "all", game: null, rows: [...rows].sort(endingSoonestFirst) }]; + } + + const byGame = new Map(); + for (const row of rows) { + byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]); + } + return [...byGame].map(([game, laneRows]) => ({ id: game, game, rows: laneRows })); +} diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index ac8316d..0cd94e7 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -4,6 +4,7 @@ import type { Region } from "../../shared/schema.ts"; import { guessRegion } from "../../shared/time.ts"; import type { SortMode } from "./sort.ts"; import { KEYS, readJson, writeJson } from "./storage.ts"; +import type { TimelineGroup } from "./lanes.ts"; import { DEFAULT_DAY_WIDTH } from "./zoom.ts"; /** @@ -72,6 +73,18 @@ export interface Prefs { * from an older export — or a corrupted one — land on something renderable. */ timelineDayWidth: number; + /** + * How the timeline stacks its bars: a lane per game, or every game together + * in deadline order. + * + * Remembered for the same reason `view` and `timelineDayWidth` are — it is + * the reader's answer to "how do I read this?", and a board that went back to + * lanes on every reload would make them say it again each time. + * + * Defaults to `"game"`, which is the board every existing reader already has. + * A stored pref wins, so shipping this moves nobody's view. + */ + timelineGroup: TimelineGroup; /** * Whether to guess which events repeat daily from what the source printed. * Off leaves only the ones the reader marked themselves; it never discards a @@ -99,6 +112,7 @@ function defaults(): Prefs { sort: "ending", view: "soon", timelineDayWidth: DEFAULT_DAY_WIDTH, + timelineGroup: "game", detectDaily: false, showCompleted: true, showIgnored: false, diff --git a/test/views.test.tsx b/test/views.test.tsx index 0214aba..0bea4aa 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { NextUp } from "../src/client/components/NextUp.tsx"; import { boardWindow } from "../src/client/components/Timeline.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 { clockFor } from "../src/shared/time.ts"; @@ -151,3 +152,52 @@ describe("Timeline window", () => { expect(max).toBeGreaterThan(NOW); }); }); + +describe("timelineLanes", () => { + // Deliberately not in deadline order, and with a game interleaved, so the + // two modes cannot both pass by accident. + const rows = [ + row("Closing Ceremony", "genshin", 100), + row("Second Wind", "hsr", 6), + row("Third Rail", "genshin", 30), + row("Open Ended", "zzz", null), + ]; + + test("by game: a lane each, and the order inside one is left alone", () => { + // The rows arrive sorted by whatever the reader chose in the list. + // Grouping them is not a licence to re-sort within a game. + const lanes = timelineLanes(rows, "game"); + expect(lanes.map((l) => l.game)).toEqual(["genshin", "hsr", "zzz"]); + expect(lanes[0]?.rows.map((r) => r.event.title)).toEqual([ + "Closing Ceremony", + "Third Rail", + ]); + }); + + test("ending soonest: every game in one stack, deadline order", () => { + const lanes = timelineLanes(rows, "ending"); + expect(lanes).toHaveLength(1); + // No heading to name the game, so the renderer has to say it per bar. + expect(lanes[0]?.game).toBeNull(); + expect(lanes[0]?.rows.map((r) => r.event.title)).toEqual([ + "Second Wind", + "Third Rail", + "Closing Ceremony", + // An unannounced end is still on the board, behind every dated one — it + // is real, but it is not a deadline. + "Open Ended", + ]); + }); + + test("neither mode loses a row", () => { + for (const mode of ["game", "ending"] as const) { + const plotted = timelineLanes(rows, mode).flatMap((l) => l.rows); + expect(plotted).toHaveLength(rows.length); + } + }); + + test("an empty board is no lanes, not one empty lane", () => { + expect(timelineLanes([], "ending")).toEqual([]); + expect(timelineLanes([], "game")).toEqual([]); + }); +});