From 7a3588fbf631842b3ffa64dfc1e1b414466e4904 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 19 Aug 2026 05:31:01 +0200 Subject: [PATCH] feat(timeline): hold back unstarted events, and say where they begin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/App.tsx | 4 + src/client/components/Timeline.tsx | 239 +++++++++++++++++++++++++++-- test/views.test.tsx | 195 ++++++++++++++++++++++- 3 files changed, 426 insertions(+), 12 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index d2f6dfc..4389891 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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} /> diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index d96624f..e393dde 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -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(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. */} -
- +
+
+ + +
@@ -209,6 +250,18 @@ export function Timeline({
+ {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. */ +

+ 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. +

+ ) : (
+ {/* 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) => ( +
+ {/* 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. */} + + {markerLabel(mark)} + +
+ ))} + {/* The axis: months above, week dates below, pinned to the top. */}
{months.map((m) => ( @@ -272,7 +348,9 @@ export function Timeline({ )}
-
+ {/* The top padding is the marker band's room — see the start markers + above, which hang in it. */} +
{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({ )} {event.title} + {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. */ + + {" "} + — not started yet, begins{" "} + {dayLabel(clock.startsMs)} + + )}
+ )} ); } @@ -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 ( + + ); +} + /** * 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(); + 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}`; +} diff --git a/test/views.test.tsx b/test/views.test.tsx index 11024d3..c7c81f7 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -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( + {}} + 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("–"); + }); +});