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 && ( +
+
+ {SPLITS.map((s) => ( + + ))} +
+

+ {SPLITS.find((s) => s.split === prefs.timelineSplitUpcoming) + ?.hint} +

+
+ )} + {/* 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(