feat(timeline): make it a board, with the axis and the names pinned

The reader who was asked what good looks like named paimon.moe's timeline,
and the thing ours was missing is what that view never loses: on a wide
window we drew more calendar and nothing that said which day, whose game, or
which event was on screen. The date axis, the lane names and the event names
all scrolled away together — and a six-week bar starts weeks off the left
edge, so its name went with its start date and left a coloured rectangle.

So it is a pane that scrolls in both directions, with the axis stuck to the
top and every name stuck to the left. A name is sticky inside its own bar, so
it can never wander onto an event it does not belong to. The axis gained the
week the schedules are actually written in: a dated tick every Monday and a
brighter rule at each month.

The frozen label column that started this went away again — it stood on the
calendar and ate the names it was meant to keep. Lane names sit on their own
line instead, and "jump to today" sits in the board's header rather than
floating over the dates it sends you back to.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 03:31:58 +02:00
co-authored by Claude Opus 5
parent 1cd9579280
commit e489697540
4 changed files with 226 additions and 96 deletions
+207 -86
View File
@@ -7,6 +7,15 @@ import { URGENCY_COLOR } from "./Meter.tsx";
const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at once
/**
* How far in from the left edge of the board a pinned label sits.
*
* Both the lane names and the event names stick here rather than riding off
* with their own start dates. A six-week event begins weeks off-screen, and a
* bar whose name scrolled away with its start date is a coloured rectangle.
*/
const PIN = 8;
/**
* A sliver of time before now, so the "now" rule reads as a line in the view
* rather than merging with the border.
@@ -19,12 +28,21 @@ const HALF_DAY_LEAD = 12 * 60 * 60 * 1000;
*/
const PAST_LEAD = 7 * DAY;
/** Where the now rule sits when the board opens: a little in from the edge. */
const OPEN_INSET = 28;
/**
* One lane per game, bars spanning start→end, today pinned as a rule.
*
* The quiet view. The ending-soon list carries the page's boldness, so this
* stays flat and legible: no gradients, no rounded chrome, just position and
* length doing the work.
*
* It is a board rather than a stretch of page — its own pane, scrolling in both
* directions, with the date axis pinned to the top and every name pinned to the
* left. All three used to scroll away together, which is what made a wide
* window worse rather than better: more calendar on screen, and nothing left
* saying which day, whose game, or which event you were looking at.
*/
export function Timeline({
rows,
@@ -54,18 +72,22 @@ export function Timeline({
// off-screen; clamping to now made the past unreachable. This does both.
const min = Math.min(...starts, now) - PAST_LEAD;
const max = Math.max(...ends, now) + 2 * DAY;
// Where "now" sits, so the container can be scrolled there on open.
const nowOffset = ((now - HALF_DAY_LEAD - min) / DAY) * DAY_WIDTH;
const totalDays = Math.ceil((max - min) / DAY);
const width = totalDays * DAY_WIDTH;
const chartWidth = totalDays * DAY_WIDTH;
/** One coordinate space for everything: bars, gridlines and the now rule. */
const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH;
// Open at today rather than at the far past. Keyed on the rounded offset so
// it runs when the range changes, not every second — re-scrolling on each
// tick would fight the reader's own scrolling.
const openAt = Math.round(nowOffset);
// Open at today rather than at the far past, with a little of the past week
// still on screen — an event that began three days ago is context, not
// history. Keyed on the rounded offset so it runs when the range changes,
// not every second: re-scrolling on each tick would fight the reader.
const openAt = Math.round(Math.max(0, x(now - HALF_DAY_LEAD) - OPEN_INSET));
const jumpToNow = (behavior: ScrollBehavior) =>
scroller.current?.scrollTo({ left: openAt, behavior });
useEffect(() => {
scroller.current?.scrollTo({ left: openAt, behavior: "instant" });
jumpToNow("instant");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openAt]);
if (rows.length === 0) {
@@ -81,91 +103,162 @@ export function Timeline({
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
}
const monthTicks = monthBoundaries(min, max);
const months = monthBoundaries(min, max);
const weeks = weekBoundaries(min, max);
return (
<div ref={scroller} className="scroll-x">
<div style={{ width, minWidth: "100%" }} className="relative px-4 pb-8 pt-3">
{/* Month rule, so a bar's absolute position means something. */}
<div className="relative mb-3 h-4 border-b border-hairline">
{monthTicks.map((t) => (
<span
key={t.ms}
className="eyebrow absolute -translate-x-px whitespace-nowrap border-l border-hairline pl-1"
style={{ left: x(t.ms) }}
>
{t.label}
</span>
))}
</div>
<div
aria-hidden
className="absolute bottom-8 top-8 z-10 w-px bg-critical/70"
style={{ left: x(now) + 16 }}
<>
{/* The board's own header. The jump control lives out here rather than
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>
<button
type="button"
onClick={() => jumpToNow("smooth")}
className="text-[0.6875rem] font-medium text-faint transition-colors duration-150 hover:text-ink"
>
<span className="eyebrow absolute -top-4 left-1 text-critical">now</span>
</div>
Jump to today
</button>
</div>
<div className="space-y-4">
{[...byGame.entries()].map(([gameId, events]) => {
const game = gameMeta(gameId);
return (
<div key={gameId}>
<p className="eyebrow mb-1.5" style={{ color: game.hue }}>
{game.short}
</p>
<div className="relative h-auto space-y-1">
{events.map(({ event, clock }) => {
const unknownEnd = clock.endsMs === null;
// Only clipped if it began before the rendered window,
// which now reaches a week past the oldest running event —
// so in practice bars show their real start and the fade is
// reserved for genuinely truncated ones.
const clippedStart = clock.startsMs < min;
const left = Math.max(x(clock.startsMs), 0);
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
const done = isDone(event.id);
return (
<button
key={event.id}
type="button"
onClick={() => onOpen(event.id)}
title={event.title}
className={`relative flex h-6 items-center overflow-hidden rounded-[3px] px-1.5 text-left text-[0.6875rem] font-medium transition-opacity hover:opacity-100 ${
done ? "opacity-35" : "opacity-90"
}`}
style={{
marginLeft: left,
width: Math.max(right - left, 22),
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
// No start edge to draw when the bar begins before
// the view does.
borderLeft: clippedStart
? undefined
: `2px solid ${game.hue}`,
// Frayed right = end unannounced; faded left = started
// before now. Both are honest about what is not shown.
maskImage: edgeMask(clippedStart, unknownEnd),
color: "var(--color-ink)",
}}
>
<span className="truncate">{event.title}</span>
<span
aria-hidden
className="ml-auto size-1.5 shrink-0 rounded-full"
style={{ background: URGENCY_COLOR[clock.urgency] }}
/>
</button>
);
})}
<div
ref={scroller}
/*
* The pane scrolls, not the page — which is what lets the axis stay
* put. Capped rather than fixed: three lanes take the height they need
* and nothing scrolls vertically at all.
*/
className="scroll-pane relative max-h-[72vh] overflow-auto overscroll-x-contain"
>
<div className="relative" style={{ width: chartWidth, minWidth: "100%" }}>
{/* Gridlines first, so everything else paints over them. */}
<div aria-hidden className="pointer-events-none absolute inset-0">
{weeks.map((ms) => (
<span
key={ms}
className="absolute bottom-0 top-10 w-px bg-hairline/40"
style={{ left: x(ms) }}
/>
))}
{months.slice(1).map((m) => (
<span
key={m.ms}
className="absolute bottom-0 top-10 w-px bg-hairline"
style={{ left: x(m.ms) }}
/>
))}
</div>
{/* Now: the one rule that has to be findable from anywhere. */}
<div
aria-hidden
className="pointer-events-none absolute bottom-0 top-10 z-10 w-px bg-critical/80"
style={{ left: x(now) }}
>
<span className="eyebrow absolute top-1 -translate-x-1/2 rounded-[3px] bg-critical px-1 py-px text-[0.5625rem] leading-none text-ground">
now
</span>
</div>
{/* The axis: months above, week dates below, pinned to the top. */}
<div className="sticky top-0 z-30 h-10 border-b border-hairline bg-ground/95 backdrop-blur">
{months.map((m) => (
<span
key={m.ms}
className="eyebrow absolute top-1 whitespace-nowrap pl-1.5 text-faint"
style={{ left: x(m.ms) }}
>
{m.label}
</span>
))}
{weeks.map((ms) => (
<span
key={ms}
className="tnum absolute bottom-1 whitespace-nowrap pl-1.5 text-[0.625rem] leading-none text-faint"
style={{ left: x(ms) }}
>
{dayLabel(ms)}
</span>
))}
</div>
<div className="space-y-5 pb-8 pt-4">
{[...byGame.entries()].map(([gameId, events]) => {
const game = gameMeta(gameId);
return (
<div key={gameId}>
{/* 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. */}
<p
className="eyebrow sticky left-0 z-20 mb-1.5 w-fit bg-ground pr-2 text-[0.625rem]"
style={{ color: game.hue, paddingLeft: PIN }}
title={game.name}
>
{game.short}
</p>
<div className="relative space-y-1">
{events.map(({ event, clock }) => {
const unknownEnd = clock.endsMs === null;
// Only clipped if it began before the rendered window,
// which reaches a week past the oldest running event — so
// in practice bars show their real start and the fade is
// reserved for genuinely truncated ones.
const clippedStart = clock.startsMs < min;
const left = Math.max(x(clock.startsMs), 0);
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
const done = isDone(event.id);
return (
<button
key={event.id}
type="button"
onClick={() => onOpen(event.id)}
title={event.title}
className={`relative flex h-7 items-center rounded-[4px] px-2 text-left text-[0.6875rem] font-medium transition-opacity hover:opacity-100 ${
done ? "opacity-35" : "opacity-90"
}`}
style={{
marginLeft: left,
width: Math.max(right - left, 26),
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
// No start edge to draw when the bar begins before
// the view does.
borderLeft: clippedStart
? undefined
: `2px solid ${game.hue}`,
// Frayed right = end unannounced; faded left =
// started before the window. Both are honest about
// what is not shown.
maskImage: edgeMask(clippedStart, unknownEnd),
color: "var(--color-ink)",
}}
>
{/* Sticky clamps to the bar's own box, so a name can
never wander outside the event it belongs to. */}
<span
className="sticky truncate"
style={{ left: PIN }}
>
{event.title}
</span>
<span
aria-hidden
className="ml-auto size-1.5 shrink-0 rounded-full"
style={{ background: URGENCY_COLOR[clock.urgency] }}
/>
</button>
);
})}
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
</div>
</div>
</>
);
}
@@ -182,6 +275,34 @@ function edgeMask(clippedStart: boolean, unknownEnd: boolean): string | undefine
return undefined;
}
/** `18 Aug`, in the reader's own locale, for the week ticks. */
function dayLabel(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
}
/**
* Every Monday in range.
*
* Weeks are the unit these schedules are actually written in — a patch is six
* of them — and a tick every seven days is the densest grid that still leaves
* room for a date on it.
*/
function weekBoundaries(min: number, max: number): number[] {
const d = new Date(min);
d.setUTCHours(0, 0, 0, 0);
// 0 is Sunday; step forward to the next Monday.
d.setUTCDate(d.getUTCDate() + ((8 - d.getUTCDay()) % 7));
const out: number[] = [];
while (d.getTime() <= max) {
out.push(d.getTime());
d.setUTCDate(d.getUTCDate() + 7);
}
return out;
}
function monthBoundaries(min: number, max: number) {
const short = (ms: number) =>
new Date(ms).toLocaleDateString(undefined, { month: "short" });