feat(timeline): hold back unstarted events, and say where they begin

Two halves of one reading. The board answers "how does the time I am in
lay out?", and plotting every lane's next patch stretched the window
weeks past today and squeezed the running bars down to nothing — so it
holds them back, and holds them back itself rather than being handed a
shorter list, because the control has to say *how many*. A board that
quietly withheld a third of the schedule is indistinguishable from a
quiet fortnight, which is the wrong thing for this app to imply by
accident. A board with nothing left running says so and points at the
control instead of reading as an empty calendar.

Switched on, a bar drawn right of the now rule is only implicitly in the
future, and implicitly is not a standard we hold anywhere else a date is
involved. Gacha schedules are not a smooth stream of starts either — a
patch ships six things at once — so the unit is the clump: a dashed rule
per group of starts, labelled in words. Clumps merge by distance on
screen rather than by calendar, since a week apart is one mark at six
pixels a day and two at a hundred and eight, and a merged one states the
span it covers rather than claiming a date the board is not ruling at.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-19 05:31:01 +02:00
co-authored by Claude Opus 5
parent 329902d7d8
commit 7a3588fbf6
3 changed files with 426 additions and 12 deletions
+4
View File
@@ -548,6 +548,10 @@ export function App() {
onZoom={(timelineDayWidth) => update({ timelineDayWidth })}
group={prefs.timelineGroup}
onGroup={(timelineGroup) => update({ timelineGroup })}
// The board holds these back itself rather than being handed a
// shorter list, so its own control can say how many are waiting.
showUpcoming={prefs.timelineUpcoming}
onShowUpcoming={(timelineUpcoming) => update({ timelineUpcoming })}
onOpen={setOpenId}
isDone={isDone}
/>
+227 -10
View File
@@ -63,6 +63,20 @@ const MIN_BAR = 34;
*/
const TAG_FROM = 96;
/**
* How far apart two start markers have to be before both are drawn.
*
* Games ship on their own patch days, so at a wide scale several clumps land
* within a few pixels of each other and their labels overlap into an unreadable
* smear. Below this the later clump is folded into the earlier marker's count,
* which is the honest reading anyway: "eleven things start around here".
*
* Sized to the widest label the marker draws rather than to a gridline, since
* what collides is the text and not the rule. A merged marker says the span it
* covers, so nothing is claimed about a date the board is not ruling.
*/
const MARKER_GAP = 104;
/**
* One lane per game, bars spanning start→end, today pinned as a rule.
*
@@ -83,6 +97,8 @@ export function Timeline({
onZoom,
group,
onGroup,
showUpcoming,
onShowUpcoming,
onOpen,
isDone,
}: {
@@ -94,6 +110,17 @@ export function Timeline({
/** How the bars are stacked: a lane per game, or one deadline queue. */
group: TimelineGroup;
onGroup: (group: TimelineGroup) => void;
/**
* Whether events that have not started yet are plotted.
*
* Off by default (`prefs.timelineUpcoming`). The board is asked "how does the
* time I am in lay out?", and every lane has a next patch queued behind it —
* plotting those unasked stretches the window weeks past today and squeezes
* the running bars the reader came for. The count is on the control, so what
* is held back is stated rather than merely absent.
*/
showUpcoming: boolean;
onShowUpcoming: (showUpcoming: boolean) => void;
onOpen: (id: string) => void;
/**
* Asked rather than derived from the progress store: an entry exists there
@@ -105,8 +132,14 @@ export function Timeline({
const gameMeta = useGameMeta();
const scroller = useRef<HTMLDivElement>(null);
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
const starts = rows.map((r) => r.clock.startsMs);
// Held back rather than filtered away by the caller, so the control can say
// how many there are — a board that silently drops a third of the schedule is
// the same failure as a stale date, arriving as an absence.
const waiting = rows.filter((r) => r.clock.upcoming);
const plotted = showUpcoming ? rows : rows.filter((r) => !r.clock.upcoming);
const ends = plotted.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
const starts = plotted.map((r) => r.clock.startsMs);
const { min, max } = boardWindow(starts, ends, now);
const totalDays = Math.ceil((max - min) / DAY);
const chartWidth = totalDays * dayWidth;
@@ -165,7 +198,8 @@ export function Timeline({
);
}
const lanes = timelineLanes(rows, group);
const lanes = timelineLanes(plotted, group);
const marks = startMarkers(plotted, x);
const months = monthBoundaries(min, max);
const weeks = weekBoundaries(min, max);
@@ -178,8 +212,15 @@ export function Timeline({
{/* 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">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-hairline px-4 py-2.5">
<div className="flex items-center gap-2">
<StackControl value={group} onChange={onGroup} />
<UpcomingControl
on={showUpcoming}
waiting={waiting.length}
onChange={onShowUpcoming}
/>
</div>
<div className="flex items-center gap-3">
<div role="group" aria-label="Scale" className="flex items-center gap-1">
@@ -209,6 +250,18 @@ export function Timeline({
</div>
</div>
{plotted.length === 0 ? (
/* Not the same emptiness as no rows at all: everything the reader can
see is still ahead of them, and the board is hiding it on purpose.
Say which, or the control above reads as broken. */
<p className="px-4 py-10 text-sm leading-relaxed text-muted">
Nothing is running right now.{" "}
{waiting.length === 1
? "One event has not started yet"
: `${waiting.length} events have not started yet`}{" "}
switch Not started on to see when they begin.
</p>
) : (
<div
ref={scroller}
/*
@@ -248,6 +301,29 @@ export function Timeline({
</span>
</div>
{/* Where the not-yet-started events begin, said in words.
A bar drawn to the right of the now rule is the only thing saying
"this has not started", and that reads as a fact about the layout
rather than about the event — so each clump of starts gets a
dashed rule and a label counting what opens there. Below the now
rule in the stack: today is the one mark that has to win. */}
{marks.map((mark) => (
<div
key={mark.ms}
aria-hidden
className="pointer-events-none absolute bottom-0 top-10 z-[5] w-px border-l border-dashed border-faint/60"
style={{ left: x(mark.ms) }}
>
{/* Its own band under the `now` chip rather than beside it: at a
wide scale the first clump is a few pixels from today, and
sharing a line cost the label its first character to a chip
that is deliberately drawn on top of everything. */}
<span className="tnum absolute top-5 whitespace-nowrap rounded-[3px] border border-hairline bg-ground px-1 py-px text-[0.5625rem] leading-none text-muted">
{markerLabel(mark)}
</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) => (
@@ -272,7 +348,9 @@ export function Timeline({
)}
</div>
<div className="space-y-7 pb-10 pt-5">
{/* The top padding is the marker band's room — see the start markers
above, which hang in it. */}
<div className="space-y-7 pb-10 pt-9">
{lanes.map((lane) => {
const heading = lane.game === null ? null : gameMeta(lane.game);
return (
@@ -296,6 +374,7 @@ export function Timeline({
{lane.rows.map(({ event, clock }) => {
const game = gameMeta(event.game);
const unknownEnd = clock.endsMs === null;
const notStarted = clock.upcoming;
// 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
@@ -315,9 +394,12 @@ export function Timeline({
// it carries, and colour alone is not an answer once
// thirteen games share one stack.
title={
heading === null
(heading === null
? `${game.name}${event.title}`
: event.title
: event.title) +
(notStarted
? ` — not started yet, begins ${dayLabel(clock.startsMs)}`
: "")
}
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"
@@ -325,12 +407,20 @@ export function Timeline({
style={{
marginLeft: left,
width,
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
// Thinner wash on an event nobody can play yet, so
// a glance at the board separates what is running
// from what is merely scheduled without reading a
// single date.
background: `color-mix(in srgb, ${game.hue} ${
notStarted ? 11 : 22
}%, var(--color-surface))`,
// No start edge to draw when the bar begins before
// the view does.
// the view does. Dashed when the event has not
// started: the edge is a date in the future, not a
// thing that has happened.
borderLeft: clippedStart
? undefined
: `3px solid ${game.hue}`,
: `3px ${notStarted ? "dashed" : "solid"} ${game.hue}`,
// Frayed right = end unannounced; faded left =
// started before the window. Both are honest about
// what is not shown.
@@ -353,6 +443,17 @@ export function Timeline({
</span>
)}
<span className="min-w-0 truncate">{event.title}</span>
{notStarted && (
/* The dashed edge and the labelled rule above it
say this to a reader looking at the board; a
screen reader gets neither, and a bar that has
not started is not a bar that is running. */
<span className="sr-only">
{" "}
not started yet, begins{" "}
{dayLabel(clock.startsMs)}
</span>
)}
</span>
<span
aria-hidden
@@ -369,6 +470,7 @@ export function Timeline({
</div>
</div>
</div>
)}
</>
);
}
@@ -439,6 +541,50 @@ function StackControl({
);
}
/**
* Whether the board also plots what has not started yet.
*
* A switch rather than a third stacking pill, and drawn as one — it does not
* reshape the board, it changes what is on it. The count is the point: a board
* that quietly withheld nine events would be indistinguishable from a quiet
* fortnight, and "nothing scheduled" is exactly the wrong thing for this app to
* imply by accident.
*
* Absent when there is nothing waiting and it is switched off: a toggle that
* cannot change anything invites a tap that does nothing.
*/
function UpcomingControl({
on,
waiting,
onChange,
}: {
on: boolean;
waiting: number;
onChange: (on: boolean) => void;
}) {
if (waiting === 0 && !on) return null;
return (
<button
type="button"
onClick={() => onChange(!on)}
aria-pressed={on}
title={
on
? "Stop plotting events that have not started yet"
: "Also plot events that have not started yet"
}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[0.6875rem] font-medium transition-colors ${
on
? "border-ink/60 text-ink"
: "border-hairline text-faint hover:text-muted"
}`}
>
Not started
<span className="tnum text-[0.625rem] text-faint">{waiting}</span>
</button>
);
}
/**
* One step of the scale control.
*
@@ -533,3 +679,74 @@ function monthBoundaries(min: number, max: number) {
}
return out;
}
/**
* Where the not-yet-started events begin, and how many begin there.
*
* Gacha schedules are not a smooth stream of start dates: a game ships a patch
* and six things open at once, so the honest unit is the clump, not the event.
* Grouped by day and then merged again by *distance on screen*, because two
* clumps a week apart are the same mark at six pixels a day and two separate
* marks at a hundred and eight — and a merged one says the range it covers
* rather than the first date in it, which would be a date the board is not
* drawing a rule at.
*
* Pure and exported because it decides what a reader is told about the future,
* which is worth a test rather than a rendering.
*/
export function startMarkers<
T extends { clock: { upcoming: boolean; startsMs: number } },
>(
rows: readonly T[],
x: (ms: number) => number,
): Array<{ ms: number; through: number; count: number }> {
const byDay = new Map<number, { ms: number; through: number; count: number }>();
for (const row of rows) {
if (!row.clock.upcoming) continue;
const day = Math.floor(row.clock.startsMs / DAY);
const at = byDay.get(day);
if (at === undefined) {
byDay.set(day, {
ms: row.clock.startsMs,
through: row.clock.startsMs,
count: 1,
});
} else {
at.ms = Math.min(at.ms, row.clock.startsMs);
at.through = Math.max(at.through, row.clock.startsMs);
at.count += 1;
}
}
const out: Array<{ ms: number; through: number; count: number }> = [];
for (const day of [...byDay.values()].sort((a, b) => a.ms - b.ms)) {
const last = out[out.length - 1];
if (last !== undefined && x(day.ms) - x(last.ms) < MARKER_GAP) {
last.through = Math.max(last.through, day.through);
last.count += day.count;
continue;
}
out.push({ ...day });
}
return out;
}
/**
* What a start marker says out loud.
*
* The whole reason the markers exist: a bar drawn to the right of the now rule
* is only implicitly in the future, and "implicitly" is not a standard this app
* holds itself to anywhere else a date is involved.
*/
export function markerLabel(mark: {
ms: number;
through: number;
count: number;
}): string {
const from = dayLabel(mark.ms);
const to = dayLabel(mark.through);
if (mark.count === 1) return `starts ${from}`;
return from === to
? `${mark.count} start ${from}`
: `${mark.count} start ${from}${to}`;
}
+194 -1
View File
@@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { NextUp } from "../src/client/components/NextUp.tsx";
import { boardWindow, Timeline } from "../src/client/components/Timeline.tsx";
import {
boardWindow,
markerLabel,
startMarkers,
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";
@@ -57,6 +62,44 @@ function row(title: string, game: GameId, endsInHours: number | null) {
return { event, clock: clockFor(event, "europe", NOW) };
}
/**
* An event whose start is still ahead of `NOW`.
*
* Its own helper rather than a flag on `row`, because everything about it is
* different: the start is what places it, the id is cut from the start's date,
* and it is the case the board deliberately withholds.
*/
function upcoming(
title: string,
game: GameId,
startsInHours: number,
runsForHours = 240,
) {
const startsAt = new Date(NOW + startsInHours * HOUR).toISOString();
const event = GachaEvent.parse({
id: `${game}:${title.toLowerCase().replace(/\W+/g, "-")}:${startsAt.slice(0, 10)}`,
game,
title,
type: "banner",
summary: null,
startsAt,
startPrecision: "exact",
endsAt: new Date(NOW + (startsInHours + runsForHours) * HOUR).toISOString(),
endPrecision: "exact",
regionScoped: false,
regionEnds: null,
sourceUrl: "https://example.invalid/events",
sourceId: "example-events",
status: "published",
confidence: 1,
extractionMethod: "parser",
version: 1,
firstSeenAt: "2026-08-17T00:00:00.000Z",
updatedAt: "2026-08-17T00:00:00.000Z",
});
return { event, clock: clockFor(event, "europe", NOW) };
}
describe("NextUp", () => {
const rows = [
row("Closing Ceremony", "genshin", 6),
@@ -217,6 +260,8 @@ describe("Timeline stacking", () => {
onZoom={() => {}}
group={group}
onGroup={() => {}}
showUpcoming={false}
onShowUpcoming={() => {}}
onOpen={() => {}}
isDone={() => false}
/>,
@@ -252,3 +297,151 @@ describe("Timeline stacking", () => {
expect(pressed("ending")).toBe("Ending soonest");
});
});
describe("Timeline: events that have not started", () => {
const rows = [
row("Closing Ceremony", "genshin", 100),
upcoming("Frost Parade", "hsr", 3 * 24),
upcoming("Second Coming", "zzz", 3 * 24 + 2),
upcoming("Long Way Round", "wuwa", 30 * 24),
];
const board = (showUpcoming: boolean, all = rows) =>
render(
<Timeline
rows={all}
now={NOW}
dayWidth={32}
onZoom={() => {}}
group="ending"
onGroup={() => {}}
showUpcoming={showUpcoming}
onShowUpcoming={() => {}}
onOpen={() => {}}
isDone={() => false}
/>,
);
test("the board holds them back by default and says how many", () => {
// Off is the default because the board answers "how does the time I am in
// lay out?" — but an absence nobody mentioned is indistinguishable from a
// quiet fortnight, which is the wrong thing for this app to imply.
const html = board(false);
expect(html).toContain("Closing Ceremony");
expect(html).not.toContain("Frost Parade");
expect(html).not.toContain("Long Way Round");
expect(html).toContain("Not started");
expect(html).toContain(">3<");
});
test("switching it on plots them", () => {
const html = board(true);
expect(html).toContain("Frost Parade");
expect(html).toContain("Long Way Round");
});
test("the control says which way it is set", () => {
expect(board(false)).toContain('aria-pressed="false"');
expect(board(true)).toContain('aria-pressed="true"');
});
test("nothing waiting and switched off means no control at all", () => {
// A toggle that cannot change anything invites a tap that does nothing.
expect(board(false, [row("Closing Ceremony", "genshin", 100)])).not.toContain(
"Not started",
);
});
test("a board with only future events says so rather than reading empty", () => {
// Otherwise the reader is looking at "nothing to plot" while three events
// are scheduled, and the reason is a control they did not notice.
const html = board(false, rows.slice(1));
expect(html).toContain("Nothing is running right now");
expect(html).toContain("3 events have not started yet");
// The control is still there to act on what the sentence just told them.
expect(html).toContain("Not started");
});
test("each clump of starts is marked in words", () => {
const html = board(true);
// Two of them open on the same day, so that is one mark saying two.
expect(html).toContain("2 start");
// And the far one is its own mark, singular.
expect(html).toContain("starts ");
});
test("start markers are absent while the events are held back", () => {
expect(board(false)).not.toContain("2 start");
});
});
describe("startMarkers", () => {
const DAY = 86_400_000;
const at = (ms: number) => ({ clock: { upcoming: true, startsMs: ms } });
/** A generous scale, so nothing merges unless the test asks it to. */
const wide = (ms: number) => (ms / DAY) * 108;
test("events starting the same day are one mark", () => {
// A patch ships six things at once; six rules stacked on one date is not a
// reading of that, it is a smear.
const marks = startMarkers(
[at(5 * DAY), at(5 * DAY + 3600_000), at(5 * DAY + 7200_000)],
wide,
);
expect(marks).toHaveLength(1);
expect(marks[0]?.count).toBe(3);
});
test("the mark sits at the earliest start in its day", () => {
// So the rule lands on the leftmost bar of the clump rather than at a
// midnight no event actually begins at.
const marks = startMarkers([at(5 * DAY + 7200_000), at(5 * DAY)], wide);
expect(marks[0]?.ms).toBe(5 * DAY);
});
test("separate days stay separate when the scale has room", () => {
const marks = startMarkers([at(5 * DAY), at(9 * DAY)], wide);
expect(marks).toHaveLength(2);
});
test("days closer than a label are merged, and the label says the range", () => {
// At six pixels a day, four days apart is 24px — two labels on top of each
// other. Merged, and honest about what it covers.
const tight = (ms: number) => (ms / DAY) * 6;
const marks = startMarkers([at(5 * DAY), at(9 * DAY)], tight);
expect(marks).toHaveLength(1);
expect(marks[0]?.count).toBe(2);
expect(marks[0]?.ms).toBe(5 * DAY);
expect(marks[0]?.through).toBe(9 * DAY);
});
test("events already running are not starts", () => {
expect(
startMarkers([{ clock: { upcoming: false, startsMs: 5 * DAY } }], wide),
).toEqual([]);
});
});
describe("markerLabel", () => {
const DAY = 86_400_000;
test("one event says it starts", () => {
expect(markerLabel({ ms: 5 * DAY, through: 5 * DAY, count: 1 })).toStartWith(
"starts ",
);
});
test("several on one day are counted", () => {
expect(markerLabel({ ms: 5 * DAY, through: 5 * DAY, count: 4 })).toStartWith(
"4 start ",
);
});
test("a merged mark names the span it covers, not just its first day", () => {
// Claiming one date for a mark that stands for eleven days is the kind of
// small confident wrongness this codebase exists to avoid.
const label = markerLabel({ ms: 5 * DAY, through: 9 * DAY, count: 2 });
expect(label).toContain("");
});
});