feat: let the timeline stack by deadline, not only by game
Lanes keep a game's events adjacent, which is what makes the board readable for someone playing four of them — but a reader with four games 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 side by side. So the stacking becomes a choice, and the choice is remembered like the scale and the view are. The merged mode sorts with endingSoonestFirst rather than a bare end date, so the timeline and the list cannot mean different things by the same words — and an unannounced end keeps its place behind every dated one instead of pretending to a deadline. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
36f4316dba
commit
663196b5ea
@@ -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<T> {
|
||||||
|
/** 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<T extends Row>(
|
||||||
|
rows: readonly T[],
|
||||||
|
mode: TimelineGroup,
|
||||||
|
): Array<Lane<T>> {
|
||||||
|
if (mode === "ending") {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
return [{ id: "all", game: null, rows: [...rows].sort(endingSoonestFirst) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const byGame = new Map<LaneId, T[]>();
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { Region } from "../../shared/schema.ts";
|
|||||||
import { guessRegion } from "../../shared/time.ts";
|
import { guessRegion } from "../../shared/time.ts";
|
||||||
import type { SortMode } from "./sort.ts";
|
import type { SortMode } from "./sort.ts";
|
||||||
import { KEYS, readJson, writeJson } from "./storage.ts";
|
import { KEYS, readJson, writeJson } from "./storage.ts";
|
||||||
|
import type { TimelineGroup } from "./lanes.ts";
|
||||||
import { DEFAULT_DAY_WIDTH } from "./zoom.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.
|
* from an older export — or a corrupted one — land on something renderable.
|
||||||
*/
|
*/
|
||||||
timelineDayWidth: number;
|
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.
|
* 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
|
* Off leaves only the ones the reader marked themselves; it never discards a
|
||||||
@@ -99,6 +112,7 @@ function defaults(): Prefs {
|
|||||||
sort: "ending",
|
sort: "ending",
|
||||||
view: "soon",
|
view: "soon",
|
||||||
timelineDayWidth: DEFAULT_DAY_WIDTH,
|
timelineDayWidth: DEFAULT_DAY_WIDTH,
|
||||||
|
timelineGroup: "game",
|
||||||
detectDaily: false,
|
detectDaily: false,
|
||||||
showCompleted: true,
|
showCompleted: true,
|
||||||
showIgnored: false,
|
showIgnored: false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { NextUp } from "../src/client/components/NextUp.tsx";
|
import { NextUp } from "../src/client/components/NextUp.tsx";
|
||||||
import { boardWindow } from "../src/client/components/Timeline.tsx";
|
import { boardWindow } from "../src/client/components/Timeline.tsx";
|
||||||
import { Welcome } from "../src/client/components/Welcome.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 { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||||
import { metaFor } from "../src/shared/games.ts";
|
import { metaFor } from "../src/shared/games.ts";
|
||||||
import { clockFor } from "../src/shared/time.ts";
|
import { clockFor } from "../src/shared/time.ts";
|
||||||
@@ -151,3 +152,52 @@ describe("Timeline window", () => {
|
|||||||
expect(max).toBeGreaterThan(NOW);
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user