diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index 4ab5c5b..17e1345 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef } from "react"; +import { Fragment, useLayoutEffect, useRef } from "react"; import { useGameMeta } from "../state/gameMeta.tsx"; import { DAY } from "../../shared/time.ts"; import type { RowEvent } from "./EventRow.tsx"; @@ -350,6 +350,8 @@ 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); return (
{/* On its own line and pinned to the left edge, so the lane @@ -368,7 +370,7 @@ export function Timeline({ )}
- {lane.rows.map(({ event, clock }) => { + {lane.rows.map(({ event, clock }, i) => { const game = gameMeta(event.game); const unknownEnd = clock.endsMs === null; const notStarted = clock.upcoming; @@ -382,8 +384,9 @@ export function Timeline({ const width = Math.max(right - left, MIN_BAR); const done = isDone(event.id); return ( + + {i === breakAt && } + ); })}
@@ -538,6 +542,48 @@ function StackControl({ ); } +/** + * The line between what is running and what is only scheduled. + * + * The same object as a lane's name — a small label pinned to the left edge so + * it survives any scroll position — because it is doing the same job: saying + * what the bars under it are. A dashed edge and a thinner wash tell a reader + * that *this* bar has not started; they do not tell them where the running + * ones stopped, and a board read at a glance should not need the difference + * decoded per bar. + * + * In muted ink rather than a game's hue, since a hue on this board means "whose + * event is this" and this label is not about a game. + */ +function NotStarted() { + return ( +

+ Not started yet +

+ ); +} + +/** + * The index of the first row that has not started, or -1 when none has. + * + * A single index rather than a per-row test, because the label marks a + * *boundary* and there is only one: every sort this board can be given puts + * live rows before upcoming ones — `endingSoonestFirst` on the merged board, + * and both list modes in the lanes, which say so explicitly. If that ever + * stopped holding, the honest repair is to fix the order rather than to scatter + * the label wherever the sequence flips. + * + * Exported so the guarantee is a test rather than a comment. + */ +export function splitAt( + rows: readonly { clock: { upcoming: boolean } }[], +): number { + return rows.findIndex((r) => r.clock.upcoming); +} + /** * One step of the scale control. * diff --git a/test/views.test.tsx b/test/views.test.tsx index 38e90ea..227f833 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -4,6 +4,7 @@ import { NextUp } from "../src/client/components/NextUp.tsx"; import { boardWindow, markerLabel, + splitAt, startMarkers, Timeline, } from "../src/client/components/Timeline.tsx"; @@ -305,14 +306,14 @@ describe("Timeline: events that have not started", () => { upcoming("Long Way Round", "wuwa", 30 * 24), ]; - const board = (showUpcoming: boolean, all = rows) => + const board = (showUpcoming: boolean, all = rows, group: "game" | "ending" = "ending") => render( {}} - group="ending" + group={group} onGroup={() => {}} showUpcoming={showUpcoming} onOpen={() => {}} @@ -363,6 +364,51 @@ describe("Timeline: events that have not started", () => { test("start markers are absent while the events are held back", () => { expect(board(false)).not.toContain("2 start"); }); + + test("a heading marks where the running bars stop", () => { + // The dashed edge says "this bar has not started"; it does not say where + // the running ones ended, which is what a board read at a glance needs. + const html = board(true); + const at = html.indexOf("Not started yet"); + expect(at).toBeGreaterThan(html.indexOf("Closing Ceremony")); + expect(at).toBeLessThan(html.indexOf("Frost Parade")); + }); + + test("every lane gets its own, since every lane has its own boundary", () => { + // Stacked by game, "where does this game stop running?" is a different + // answer per lane — one heading for the board would be in the wrong place + // for all but one of them. + const html = board(true, rows, "game"); + expect(html.split("Not started yet")).toHaveLength(4); + }); + + 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( + "Not started yet", + ); + expect(board(false)).not.toContain("Not started yet"); + }); +}); + +describe("splitAt", () => { + const live = { clock: { upcoming: false } }; + const soon = { clock: { upcoming: true } }; + + test("finds the boundary", () => { + expect(splitAt([live, live, soon, soon])).toBe(2); + }); + + test("a lane that is all future breaks at the top", () => { + // Not a divider then but a heading, which is the honest reading: nothing + // in this lane has started. + expect(splitAt([soon, soon])).toBe(0); + }); + + test("nothing waiting is no boundary at all", () => { + expect(splitAt([live, live])).toBe(-1); + expect(splitAt([])).toBe(-1); + }); }); describe("startMarkers", () => {