feat(timeline): name the line where the running bars stop
A dashed edge and a thinner wash say "this bar has not started". They do not say where the running ones ended, so a board with the future switched on had to be decoded bar by bar to answer the question a reader opens it with — what is on now. So the boundary gets a label, and it is the same object as a lane's name: a small eyebrow pinned to the left edge, surviving any scroll position. In muted ink rather than a hue, because a hue on this board means "whose event is this" and this is not about a game. One per lane, since "where does this stop running?" is a different answer for each of them. Where it goes is a single index rather than a per-row test, because there is only one boundary: every order this board can be given puts live rows before upcoming ones, and `splitAt` is exported so that is a test rather than a comment. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
51f2097445
commit
c0e523bdc4
@@ -1,4 +1,4 @@
|
|||||||
import { useLayoutEffect, useRef } from "react";
|
import { Fragment, useLayoutEffect, useRef } from "react";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
import { DAY } from "../../shared/time.ts";
|
import { DAY } from "../../shared/time.ts";
|
||||||
import type { RowEvent } from "./EventRow.tsx";
|
import type { RowEvent } from "./EventRow.tsx";
|
||||||
@@ -350,6 +350,8 @@ export function Timeline({
|
|||||||
<div className="space-y-7 pb-10 pt-9">
|
<div className="space-y-7 pb-10 pt-9">
|
||||||
{lanes.map((lane) => {
|
{lanes.map((lane) => {
|
||||||
const heading = lane.game === null ? null : gameMeta(lane.game);
|
const heading = lane.game === null ? null : gameMeta(lane.game);
|
||||||
|
// Where this lane stops running and starts being scheduled.
|
||||||
|
const breakAt = splitAt(lane.rows);
|
||||||
return (
|
return (
|
||||||
<div key={lane.id}>
|
<div key={lane.id}>
|
||||||
{/* On its own line and pinned to the left edge, so the lane
|
{/* On its own line and pinned to the left edge, so the lane
|
||||||
@@ -368,7 +370,7 @@ export function Timeline({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
{lane.rows.map(({ event, clock }) => {
|
{lane.rows.map(({ event, clock }, i) => {
|
||||||
const game = gameMeta(event.game);
|
const game = gameMeta(event.game);
|
||||||
const unknownEnd = clock.endsMs === null;
|
const unknownEnd = clock.endsMs === null;
|
||||||
const notStarted = clock.upcoming;
|
const notStarted = clock.upcoming;
|
||||||
@@ -382,8 +384,9 @@ export function Timeline({
|
|||||||
const width = Math.max(right - left, MIN_BAR);
|
const width = Math.max(right - left, MIN_BAR);
|
||||||
const done = isDone(event.id);
|
const done = isDone(event.id);
|
||||||
return (
|
return (
|
||||||
|
<Fragment key={event.id}>
|
||||||
|
{i === breakAt && <NotStarted />}
|
||||||
<button
|
<button
|
||||||
key={event.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(event.id)}
|
onClick={() => onOpen(event.id)}
|
||||||
// The game is in the tooltip on the merged board
|
// The game is in the tooltip on the merged board
|
||||||
@@ -458,6 +461,7 @@ export function Timeline({
|
|||||||
style={{ background: URGENCY_COLOR[clock.urgency] }}
|
style={{ background: URGENCY_COLOR[clock.urgency] }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<p
|
||||||
|
className="eyebrow sticky left-0 z-20 w-fit bg-ground pb-0.5 pr-2 pt-2 text-[0.625rem] text-faint"
|
||||||
|
style={{ paddingLeft: PIN }}
|
||||||
|
>
|
||||||
|
Not started yet
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* One step of the scale control.
|
||||||
*
|
*
|
||||||
|
|||||||
+48
-2
@@ -4,6 +4,7 @@ import { NextUp } from "../src/client/components/NextUp.tsx";
|
|||||||
import {
|
import {
|
||||||
boardWindow,
|
boardWindow,
|
||||||
markerLabel,
|
markerLabel,
|
||||||
|
splitAt,
|
||||||
startMarkers,
|
startMarkers,
|
||||||
Timeline,
|
Timeline,
|
||||||
} from "../src/client/components/Timeline.tsx";
|
} from "../src/client/components/Timeline.tsx";
|
||||||
@@ -305,14 +306,14 @@ describe("Timeline: events that have not started", () => {
|
|||||||
upcoming("Long Way Round", "wuwa", 30 * 24),
|
upcoming("Long Way Round", "wuwa", 30 * 24),
|
||||||
];
|
];
|
||||||
|
|
||||||
const board = (showUpcoming: boolean, all = rows) =>
|
const board = (showUpcoming: boolean, all = rows, group: "game" | "ending" = "ending") =>
|
||||||
render(
|
render(
|
||||||
<Timeline
|
<Timeline
|
||||||
rows={all}
|
rows={all}
|
||||||
now={NOW}
|
now={NOW}
|
||||||
dayWidth={32}
|
dayWidth={32}
|
||||||
onZoom={() => {}}
|
onZoom={() => {}}
|
||||||
group="ending"
|
group={group}
|
||||||
onGroup={() => {}}
|
onGroup={() => {}}
|
||||||
showUpcoming={showUpcoming}
|
showUpcoming={showUpcoming}
|
||||||
onOpen={() => {}}
|
onOpen={() => {}}
|
||||||
@@ -363,6 +364,51 @@ describe("Timeline: events that have not started", () => {
|
|||||||
test("start markers are absent while the events are held back", () => {
|
test("start markers are absent while the events are held back", () => {
|
||||||
expect(board(false)).not.toContain("2 start");
|
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", () => {
|
describe("startMarkers", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user