feat: put the stacking toggle on the board

The board's header said "One lane per game" and left it at that; now it
offers the other stacking too, in the same place and the same pill shape
the list's sort control uses — you reach for it while looking at the
board, not in settings.

Merged, there is no lane heading to say whose event a bar is, and hue
alone cannot answer that once every game shares one stack. So each bar
carries its game's short name, and its tooltip carries the full one. A
bar under 96px has no room for a tag and a title both, and a chopped
game name reads as a broken word — those keep the title and say the game
in the tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 22:16:00 +02:00
co-authored by Claude Opus 5
parent 663196b5ea
commit 1c8b71e7bf
3 changed files with 159 additions and 23 deletions
+2
View File
@@ -535,6 +535,8 @@ export function App() {
// reader edited, and a board one pixel wide is not a preference.
dayWidth={snapDayWidth(prefs.timelineDayWidth)}
onZoom={(timelineDayWidth) => update({ timelineDayWidth })}
group={prefs.timelineGroup}
onGroup={(timelineGroup) => update({ timelineGroup })}
onOpen={setOpenId}
isDone={isDone}
/>
+101 -18
View File
@@ -1,9 +1,13 @@
import { useLayoutEffect, useRef } from "react";
import { useGameMeta } from "../state/gameMeta.tsx";
import type { LaneId } from "../../shared/custom.ts";
import { DAY } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { URGENCY_COLOR } from "./Meter.tsx";
import {
timelineLanes,
TIMELINE_GROUPS,
type TimelineGroup,
} from "../state/lanes.ts";
import { canStep, stepDayWidth, weekLabelStep } from "../state/zoom.ts";
/**
@@ -45,6 +49,20 @@ const PAST_LIMIT = 60 * DAY;
/** Where the now rule sits when the board opens: a little in from the edge. */
const OPEN_INSET = 28;
/** The narrowest a bar is drawn, so a two-day event is still a target. */
const MIN_BAR = 34;
/**
* How wide a bar has to be before the merged board tags it with its game.
*
* The tag is what replaces the lane heading, so it wants to be on every bar —
* but a bar narrower than this has no room for the tag *and* a title, and what
* a reader gets is a truncated game name reading as a broken word. Below the
* threshold the hue and the tooltip carry it, which is what a two-day bar could
* say about itself anyway.
*/
const TAG_FROM = 96;
/**
* One lane per game, bars spanning start→end, today pinned as a rule.
*
@@ -63,6 +81,8 @@ export function Timeline({
now,
dayWidth,
onZoom,
group,
onGroup,
onOpen,
isDone,
}: {
@@ -71,6 +91,9 @@ export function Timeline({
/** How wide one day is, in px. Snapped to the ladder in `state/zoom.ts`. */
dayWidth: number;
onZoom: (dayWidth: number) => void;
/** How the bars are stacked: a lane per game, or one deadline queue. */
group: TimelineGroup;
onGroup: (group: TimelineGroup) => void;
onOpen: (id: string) => void;
/**
* Asked rather than derived from the progress store: an entry exists there
@@ -142,10 +165,7 @@ export function Timeline({
);
}
const byGame = new Map<LaneId, RowEvent[]>();
for (const row of rows) {
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
}
const lanes = timelineLanes(rows, group);
const months = monthBoundaries(min, max);
const weeks = weekBoundaries(min, max);
@@ -159,7 +179,7 @@ export function Timeline({
floating over the chart: pinned inside, it would sit on top of the
calendar and cover the very dates it sends you back to. */}
<div className="flex items-center justify-between gap-3 border-b border-hairline px-4 py-2.5">
<p className="eyebrow">One lane per game</p>
<StackControl value={group} onChange={onGroup} />
<div className="flex items-center gap-3">
<div role="group" aria-label="Scale" className="flex items-center gap-1">
@@ -253,23 +273,28 @@ export function Timeline({
</div>
<div className="space-y-7 pb-10 pt-5">
{[...byGame.entries()].map(([gameId, events]) => {
const game = gameMeta(gameId);
{lanes.map((lane) => {
const heading = lane.game === null ? null : gameMeta(lane.game);
return (
<div key={gameId}>
<div key={lane.id}>
{/* On its own line and pinned to the left edge, so the lane
keeps its name at any scroll position without a frozen
column standing on top of the calendar. */}
column standing on top of the calendar. Absent on the
merged board, where there is no one game to name — each
bar carries its own instead. */}
{heading !== null && (
<p
className="eyebrow sticky left-0 z-20 mb-2.5 w-fit bg-ground pr-2 text-[0.625rem]"
style={{ color: game.hue, paddingLeft: PIN }}
title={game.name}
style={{ color: heading.hue, paddingLeft: PIN }}
title={heading.name}
>
{game.short}
{heading.short}
</p>
)}
<div className="relative space-y-2">
{events.map(({ event, clock }) => {
{lane.rows.map(({ event, clock }) => {
const game = gameMeta(event.game);
const unknownEnd = clock.endsMs === null;
// Only clipped if it began before the rendered window,
// which reaches a week past the oldest running event — so
@@ -278,19 +303,28 @@ export function Timeline({
const clippedStart = clock.startsMs < min;
const left = Math.max(x(clock.startsMs), 0);
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
const width = Math.max(right - left, MIN_BAR);
const done = isDone(event.id);
return (
<button
key={event.id}
type="button"
onClick={() => onOpen(event.id)}
title={event.title}
// The game is in the tooltip on the merged board
// because the bar can be too narrow to show the tag
// it carries, and colour alone is not an answer once
// thirteen games share one stack.
title={
heading === null
? `${game.name}${event.title}`
: event.title
}
className={`relative flex h-9 items-center gap-2 rounded-[5px] px-3 text-left text-[0.75rem] font-medium transition-opacity hover:opacity-100 ${
done ? "opacity-35" : "opacity-90"
}`}
style={{
marginLeft: left,
width: Math.max(right - left, 34),
width,
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
// No start edge to draw when the bar begins before
// the view does.
@@ -307,10 +341,18 @@ export function Timeline({
{/* Sticky clamps to the bar's own box, so a name can
never wander outside the event it belongs to. */}
<span
className="sticky truncate"
className="sticky flex min-w-0 items-center gap-1.5 truncate"
style={{ left: PIN }}
>
{event.title}
{heading === null && width >= TAG_FROM && (
<span
className="eyebrow shrink-0 text-[0.5625rem]"
style={{ color: game.hue }}
>
{game.short}
</span>
)}
<span className="min-w-0 truncate">{event.title}</span>
</span>
<span
aria-hidden
@@ -356,6 +398,47 @@ export function boardWindow(
};
}
/**
* Lanes, or one queue.
*
* Sits in the board's own header rather than down in settings, for the reason
* the list's sort control does: it is something a reader reaches for while
* looking at the board, not a preference they go and configure. It reads as a
* pair of pills rather than the view tabs' segmented control, because it
* reshapes the thing below it instead of replacing it.
*/
function StackControl({
value,
onChange,
}: {
value: TimelineGroup;
onChange: (group: TimelineGroup) => void;
}) {
return (
<div role="group" aria-label="Stack the timeline" className="flex gap-1">
{TIMELINE_GROUPS.map((mode) => {
const on = value === mode.id;
return (
<button
key={mode.id}
type="button"
onClick={() => onChange(mode.id)}
aria-pressed={on}
title={mode.hint}
className={`rounded-full border px-2.5 py-1 text-[0.6875rem] font-medium transition-colors ${
on
? "border-ink/60 text-ink"
: "border-transparent text-faint hover:text-muted"
}`}
>
{mode.label}
</button>
);
})}
</div>
);
}
/**
* One step of the scale control.
*
+52 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { NextUp } from "../src/client/components/NextUp.tsx";
import { boardWindow } from "../src/client/components/Timeline.tsx";
import { boardWindow, Timeline } from "../src/client/components/Timeline.tsx";
import { Welcome } from "../src/client/components/Welcome.tsx";
import { timelineLanes } from "../src/client/state/lanes.ts";
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
@@ -201,3 +201,54 @@ describe("timelineLanes", () => {
expect(timelineLanes([], "game")).toEqual([]);
});
});
describe("Timeline stacking", () => {
const rows = [
row("Closing Ceremony", "genshin", 100),
row("Second Wind", "hsr", 6),
];
const board = (group: "game" | "ending") =>
render(
<Timeline
rows={rows}
now={NOW}
dayWidth={13}
onZoom={() => {}}
group={group}
onGroup={() => {}}
onOpen={() => {}}
isDone={() => false}
/>,
);
test("both stackings plot every event", () => {
for (const group of ["game", "ending"] as const) {
const html = board(group);
expect(html).toContain("Closing Ceremony");
expect(html).toContain("Second Wind");
}
});
test("the merged board names each bar's game, since no heading does", () => {
// Colour cannot carry it once every game shares one stack, and a reader
// who cannot tell whose event is ending tonight has not been told the
// thing they came for.
const html = board("ending");
expect(html).toContain(metaFor("hsr", {}).short);
expect(html).toContain(metaFor("genshin", {}).short);
// Deadline order, across games.
expect(html.indexOf("Second Wind")).toBeLessThan(
html.indexOf("Closing Ceremony"),
);
});
test("the reader can see which stacking they are on", () => {
// The control is the only thing on the board saying which of the two
// shapes they are reading, so it has to say it, not just accept a tap.
const pressed = (group: "game" | "ending") =>
/aria-pressed="true"[\s\S]*?>([^<]+)</.exec(board(group))?.[1];
expect(pressed("game")).toBe("By game");
expect(pressed("ending")).toBe("Ending soonest");
});
});