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:
Lucas Winther
2026-08-18 22:15:22 +02:00
co-authored by Claude Opus 5
parent 36f4316dba
commit 663196b5ea
3 changed files with 147 additions and 0 deletions
+83
View File
@@ -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 }));
}
+14
View File
@@ -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,