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:
co-authored by
Claude Opus 5
parent
663196b5ea
commit
1c8b71e7bf
@@ -535,6 +535,8 @@ export function App() {
|
|||||||
// reader edited, and a board one pixel wide is not a preference.
|
// reader edited, and a board one pixel wide is not a preference.
|
||||||
dayWidth={snapDayWidth(prefs.timelineDayWidth)}
|
dayWidth={snapDayWidth(prefs.timelineDayWidth)}
|
||||||
onZoom={(timelineDayWidth) => update({ timelineDayWidth })}
|
onZoom={(timelineDayWidth) => update({ timelineDayWidth })}
|
||||||
|
group={prefs.timelineGroup}
|
||||||
|
onGroup={(timelineGroup) => update({ timelineGroup })}
|
||||||
onOpen={setOpenId}
|
onOpen={setOpenId}
|
||||||
isDone={isDone}
|
isDone={isDone}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useLayoutEffect, useRef } from "react";
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
import type { LaneId } from "../../shared/custom.ts";
|
|
||||||
import { DAY } from "../../shared/time.ts";
|
import { DAY } from "../../shared/time.ts";
|
||||||
import type { RowEvent } from "./EventRow.tsx";
|
import type { RowEvent } from "./EventRow.tsx";
|
||||||
import { URGENCY_COLOR } from "./Meter.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";
|
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. */
|
/** Where the now rule sits when the board opens: a little in from the edge. */
|
||||||
const OPEN_INSET = 28;
|
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.
|
* One lane per game, bars spanning start→end, today pinned as a rule.
|
||||||
*
|
*
|
||||||
@@ -63,6 +81,8 @@ export function Timeline({
|
|||||||
now,
|
now,
|
||||||
dayWidth,
|
dayWidth,
|
||||||
onZoom,
|
onZoom,
|
||||||
|
group,
|
||||||
|
onGroup,
|
||||||
onOpen,
|
onOpen,
|
||||||
isDone,
|
isDone,
|
||||||
}: {
|
}: {
|
||||||
@@ -71,6 +91,9 @@ export function Timeline({
|
|||||||
/** How wide one day is, in px. Snapped to the ladder in `state/zoom.ts`. */
|
/** How wide one day is, in px. Snapped to the ladder in `state/zoom.ts`. */
|
||||||
dayWidth: number;
|
dayWidth: number;
|
||||||
onZoom: (dayWidth: number) => void;
|
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;
|
onOpen: (id: string) => void;
|
||||||
/**
|
/**
|
||||||
* Asked rather than derived from the progress store: an entry exists there
|
* 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[]>();
|
const lanes = timelineLanes(rows, group);
|
||||||
for (const row of rows) {
|
|
||||||
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const months = monthBoundaries(min, max);
|
const months = monthBoundaries(min, max);
|
||||||
const weeks = weekBoundaries(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
|
floating over the chart: pinned inside, it would sit on top of the
|
||||||
calendar and cover the very dates it sends you back to. */}
|
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">
|
<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 className="flex items-center gap-3">
|
||||||
<div role="group" aria-label="Scale" className="flex items-center gap-1">
|
<div role="group" aria-label="Scale" className="flex items-center gap-1">
|
||||||
@@ -253,23 +273,28 @@ export function Timeline({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-7 pb-10 pt-5">
|
<div className="space-y-7 pb-10 pt-5">
|
||||||
{[...byGame.entries()].map(([gameId, events]) => {
|
{lanes.map((lane) => {
|
||||||
const game = gameMeta(gameId);
|
const heading = lane.game === null ? null : gameMeta(lane.game);
|
||||||
return (
|
return (
|
||||||
<div key={gameId}>
|
<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
|
||||||
keeps its name at any scroll position without a frozen
|
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
|
||||||
<p
|
merged board, where there is no one game to name — each
|
||||||
className="eyebrow sticky left-0 z-20 mb-2.5 w-fit bg-ground pr-2 text-[0.625rem]"
|
bar carries its own instead. */}
|
||||||
style={{ color: game.hue, paddingLeft: PIN }}
|
{heading !== null && (
|
||||||
title={game.name}
|
<p
|
||||||
>
|
className="eyebrow sticky left-0 z-20 mb-2.5 w-fit bg-ground pr-2 text-[0.625rem]"
|
||||||
{game.short}
|
style={{ color: heading.hue, paddingLeft: PIN }}
|
||||||
</p>
|
title={heading.name}
|
||||||
|
>
|
||||||
|
{heading.short}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative space-y-2">
|
<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;
|
const unknownEnd = clock.endsMs === null;
|
||||||
// Only clipped if it began before the rendered window,
|
// Only clipped if it began before the rendered window,
|
||||||
// which reaches a week past the oldest running event — so
|
// which reaches a week past the oldest running event — so
|
||||||
@@ -278,19 +303,28 @@ export function Timeline({
|
|||||||
const clippedStart = clock.startsMs < min;
|
const clippedStart = clock.startsMs < min;
|
||||||
const left = Math.max(x(clock.startsMs), 0);
|
const left = Math.max(x(clock.startsMs), 0);
|
||||||
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
|
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
|
||||||
|
const width = Math.max(right - left, MIN_BAR);
|
||||||
const done = isDone(event.id);
|
const done = isDone(event.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={event.id}
|
key={event.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(event.id)}
|
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 ${
|
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"
|
done ? "opacity-35" : "opacity-90"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: left,
|
marginLeft: left,
|
||||||
width: Math.max(right - left, 34),
|
width,
|
||||||
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
|
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
|
||||||
// No start edge to draw when the bar begins before
|
// No start edge to draw when the bar begins before
|
||||||
// the view does.
|
// the view does.
|
||||||
@@ -307,10 +341,18 @@ export function Timeline({
|
|||||||
{/* Sticky clamps to the bar's own box, so a name can
|
{/* Sticky clamps to the bar's own box, so a name can
|
||||||
never wander outside the event it belongs to. */}
|
never wander outside the event it belongs to. */}
|
||||||
<span
|
<span
|
||||||
className="sticky truncate"
|
className="sticky flex min-w-0 items-center gap-1.5 truncate"
|
||||||
style={{ left: PIN }}
|
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>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
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.
|
* One step of the scale control.
|
||||||
*
|
*
|
||||||
|
|||||||
+52
-1
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { NextUp } from "../src/client/components/NextUp.tsx";
|
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 { Welcome } from "../src/client/components/Welcome.tsx";
|
||||||
import { timelineLanes } from "../src/client/state/lanes.ts";
|
import { timelineLanes } from "../src/client/state/lanes.ts";
|
||||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||||
@@ -201,3 +201,54 @@ describe("timelineLanes", () => {
|
|||||||
expect(timelineLanes([], "game")).toEqual([]);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user