diff --git a/src/client/App.tsx b/src/client/App.tsx
index 9b6da92..0a639ee 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -552,6 +552,7 @@ export function App() {
// shorter list, so it can say how many are waiting when there is
// nothing else left to draw. The switch is in settings.
showUpcoming={prefs.timelineUpcoming}
+ splitUpcoming={prefs.timelineSplitUpcoming}
onOpen={setOpenId}
isDone={isDone}
/>
diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx
index dd2c20d..f43635e 100644
--- a/src/client/components/Controls.tsx
+++ b/src/client/components/Controls.tsx
@@ -21,6 +21,28 @@ const THEMES: Array<{ id: ThemeChoice; label: string }> = [
{ id: "system", label: "System" },
];
+/**
+ * The two readings of a board with the future on it, and both are right for
+ * somebody — see PRD F1.
+ *
+ * Kept as a pair of pills rather than a second checkbox because neither answer
+ * is the absence of the other: "mixed in" is a different order, not a heading
+ * switched off. A checkbox would name one of them and leave the other as
+ * whatever is left over.
+ */
+const SPLITS: Array<{ split: boolean; label: string; hint: string }> = [
+ {
+ split: true,
+ label: "In their own group",
+ hint: "Each lane runs out, then a \u201cNot started yet\u201d heading and what is queued behind it.",
+ },
+ {
+ split: false,
+ label: "Mixed in",
+ hint: "One deadline order, started or not — so something opening Friday and closing Sunday sits above an event running until October.",
+ },
+];
+
export function Controls({
games,
prefs,
@@ -160,6 +182,39 @@ export function Controls({
+ {/* Only while there is something to arrange. A choice about how
+ unstarted events sit on the board is unanswerable when none
+ are on it, and offering it anyway is a control that does
+ nothing — the stored answer is kept either way, so switching
+ the row above back on restores it rather than a default. */}
+ {prefs.timelineUpcoming && (
+
+ )}
+
{/* Detection reads the source's wording and is wrong in both
directions, so it ships off and says so. Off leaves only the
events the reader marked, and discards nothing — every mark and
diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx
index 17e1345..8d0d838 100644
--- a/src/client/components/Timeline.tsx
+++ b/src/client/components/Timeline.tsx
@@ -98,6 +98,7 @@ export function Timeline({
group,
onGroup,
showUpcoming,
+ splitUpcoming,
onOpen,
isDone,
}: {
@@ -124,6 +125,18 @@ export function Timeline({
* decides what is on it at all.
*/
showUpcoming: boolean;
+ /**
+ * Whether those unstarted events keep to their own block under a heading, or
+ * sit in one deadline order with the running ones
+ * (`prefs.timelineSplitUpcoming`, and the switch is in settings beside the
+ * one above).
+ *
+ * Mixed is not merely the heading switched off: the orders this board is
+ * given all hold unstarted rows behind running ones, so dropping the label
+ * alone would leave the same block with nothing explaining it. `lanes.ts`
+ * re-sorts instead, and this only decides whether the heading is drawn.
+ */
+ splitUpcoming: boolean;
onOpen: (id: string) => void;
/**
* Asked rather than derived from the progress store: an entry exists there
@@ -201,7 +214,7 @@ export function Timeline({
);
}
- const lanes = timelineLanes(plotted, group);
+ const lanes = timelineLanes(plotted, group, splitUpcoming);
const marks = startMarkers(plotted, x);
const months = monthBoundaries(min, max);
@@ -351,7 +364,9 @@ export function Timeline({
{lanes.map((lane) => {
const heading = lane.game === null ? null : gameMeta(lane.game);
// Where this lane stops running and starts being scheduled.
- const breakAt = splitAt(lane.rows);
+ // Mixed in, there is no such place — the rows are one deadline
+ // queue and a heading would be pointing at the middle of it.
+ const breakAt = splitUpcoming ? splitAt(lane.rows) : -1;
return (
{/* On its own line and pinned to the left edge, so the lane
diff --git a/test/controls.test.tsx b/test/controls.test.tsx
index 8b784b7..31351e8 100644
--- a/test/controls.test.tsx
+++ b/test/controls.test.tsx
@@ -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"[^>]*>([^<]+) 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.
diff --git a/test/views.test.tsx b/test/views.test.tsx
index 227f833..839e01d 100644
--- a/test/views.test.tsx
+++ b/test/views.test.tsx
@@ -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(
{
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(