feat(timeline): offer both readings of a board with the future on it

"In their own group" is the shape of a patch — what is on now, and what
is queued behind it. "Mixed in" is the question a Gantt chart exists for:
what runs out first, whether or not it has opened. On real data that is
not a cosmetic difference — mixed, five events currently running sort
below an Arknights rerun that has not started, because it closes before
they do.

Which is why it is a pair of pills and not a checkbox: neither answer is
the absence of the other, and "mixed in" is a different order rather than
the heading switched off. It sits under the row that puts the future on
the board at all, and only while that row is ticked — a choice about
arranging unstarted events is unanswerable with none of them on screen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-19 06:05:58 +02:00
co-authored by Claude Opus 5
parent 9dde82801a
commit 3db93a1ccf
5 changed files with 145 additions and 3 deletions
+27
View File
@@ -93,6 +93,33 @@ describe("Controls: what am I allowed to look at", () => {
expect(on.filter(Boolean)).toHaveLength(2);
});
test("how unstarted events sit on the board is offered only when they are", () => {
// A choice about arranging them is unanswerable with none on the board,
// and a control that changes nothing visible is worse than none.
expect(render(PREFS)).not.toContain("Mixed in");
const on = render({ ...PREFS, timelineUpcoming: true });
expect(on).toContain("In their own group");
expect(on).toContain("Mixed in");
});
test("it is a pair of answers, not one answer and its absence", () => {
// "Mixed in" is a different order, not a heading switched off, so both
// states name themselves and the panel says which is on.
const split = render({ ...PREFS, timelineUpcoming: true });
const mixed = render({
...PREFS,
timelineUpcoming: true,
timelineSplitUpcoming: false,
});
const pressed = (html: string) =>
[...html.matchAll(/aria-pressed="true"[^>]*>([^<]+)</g)].map((m) => m[1]);
expect(pressed(split)).toContain("In their own group");
expect(pressed(mixed)).toContain("Mixed in");
// And the line under them describes the answer that is actually on.
expect(mixed).toContain("One deadline order");
expect(split).not.toContain("One deadline order");
});
test("the ignored row appears only once something is ignored", () => {
// Nothing to restore means nothing to offer — the row would be a filter
// over an empty set.
+45 -1
View File
@@ -262,11 +262,23 @@ describe("Timeline stacking", () => {
group={group}
onGroup={() => {}}
showUpcoming={false}
splitUpcoming
onOpen={() => {}}
isDone={() => false}
/>,
);
test("mixed in, lane mode re-sorts rather than keeping the block", () => {
// The one place `timelineLanes` is allowed to reorder a lane. Leaving the
// given order alone would draw exactly the block it was told not to, minus
// the heading that explained it.
// Given live-first, as every sort this board can be handed produces. B has
// the nearer end (48h against 100h) but has not opened yet.
const given = [row("A", "hsr", 100), upcoming("B", "hsr", 24, 24)];
expect(timelineLanes(given, "game", true)[0]?.rows[0]?.event.title).toBe("A");
expect(timelineLanes(given, "game", false)[0]?.rows[0]?.event.title).toBe("B");
});
test("both stackings plot every event", () => {
for (const group of ["game", "ending"] as const) {
const html = board(group);
@@ -306,7 +318,12 @@ describe("Timeline: events that have not started", () => {
upcoming("Long Way Round", "wuwa", 30 * 24),
];
const board = (showUpcoming: boolean, all = rows, group: "game" | "ending" = "ending") =>
const board = (
showUpcoming: boolean,
all = rows,
group: "game" | "ending" = "ending",
splitUpcoming = true,
) =>
render(
<Timeline
rows={all}
@@ -316,6 +333,7 @@ describe("Timeline: events that have not started", () => {
group={group}
onGroup={() => {}}
showUpcoming={showUpcoming}
splitUpcoming={splitUpcoming}
onOpen={() => {}}
isDone={() => false}
/>,
@@ -382,6 +400,32 @@ describe("Timeline: events that have not started", () => {
expect(html.split("Not started yet")).toHaveLength(4);
});
test("mixed in, there is no block to head and no heading", () => {
// Not the heading switched off: the rows are one deadline queue, so a
// label would be pointing at the middle of it.
const html = board(true, rows, "ending", false);
expect(html).toContain("Frost Parade");
expect(html).not.toContain("Not started yet");
});
test("mixed in, a nearer deadline wins whether or not it has opened", () => {
// The whole point of the option, and the one thing the split order can
// never show. Frost Parade opens in 3 days and closes 10 days after that;
// Closing Ceremony is running now until 100 hours from now — so it is
// still the nearer deadline, and Frost Parade sits under it rather than
// behind every running row.
const near = upcoming("Quick Turnaround", "hsr", 24, 24);
const html = board(true, [row("Closing Ceremony", "genshin", 100), near], "ending", false);
expect(html.indexOf("Quick Turnaround")).toBeLessThan(
html.indexOf("Closing Ceremony"),
);
// Split, the same two rows go the other way round.
const kept = board(true, [row("Closing Ceremony", "genshin", 100), near], "ending", true);
expect(kept.indexOf("Closing Ceremony")).toBeLessThan(
kept.indexOf("Quick Turnaround"),
);
});
test("no heading where nothing is waiting", () => {
// A label with nothing under it is a section that does not exist.
expect(board(true, [row("Closing Ceremony", "genshin", 100)])).not.toContain(