From 1c8b71e7bfb5c62e6dba16237d8573ae7b09fae1 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 18 Aug 2026 22:10:33 +0200 Subject: [PATCH] feat: put the stacking toggle on the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/App.tsx | 2 + src/client/components/Timeline.tsx | 127 ++++++++++++++++++++++++----- test/views.test.tsx | 53 +++++++++++- 3 files changed, 159 insertions(+), 23 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 0f791c3..5082ff2 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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} /> diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index df3a967..d96624f 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -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(); - 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. */}
-

One lane per game

+
@@ -253,23 +273,28 @@ export function Timeline({
- {[...byGame.entries()].map(([gameId, events]) => { - const game = gameMeta(gameId); + {lanes.map((lane) => { + const heading = lane.game === null ? null : gameMeta(lane.game); return ( -
+
{/* 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. */} -

- {game.short} -

+ 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 && ( +

+ {heading.short} +

+ )}
- {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 ( + ); + })} +
+ ); +} + /** * One step of the scale control. * diff --git a/test/views.test.tsx b/test/views.test.tsx index 0bea4aa..11024d3 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -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( + {}} + 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]*?>([^<]+)