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:
co-authored by
Claude Opus 5
parent
329902d7d8
commit
7a3588fbf6
@@ -548,6 +548,10 @@ export function App() {
|
|||||||
onZoom={(timelineDayWidth) => update({ timelineDayWidth })}
|
onZoom={(timelineDayWidth) => update({ timelineDayWidth })}
|
||||||
group={prefs.timelineGroup}
|
group={prefs.timelineGroup}
|
||||||
onGroup={(timelineGroup) => update({ 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}
|
onOpen={setOpenId}
|
||||||
isDone={isDone}
|
isDone={isDone}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ const MIN_BAR = 34;
|
|||||||
*/
|
*/
|
||||||
const TAG_FROM = 96;
|
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.
|
* One lane per game, bars spanning start→end, today pinned as a rule.
|
||||||
*
|
*
|
||||||
@@ -83,6 +97,8 @@ export function Timeline({
|
|||||||
onZoom,
|
onZoom,
|
||||||
group,
|
group,
|
||||||
onGroup,
|
onGroup,
|
||||||
|
showUpcoming,
|
||||||
|
onShowUpcoming,
|
||||||
onOpen,
|
onOpen,
|
||||||
isDone,
|
isDone,
|
||||||
}: {
|
}: {
|
||||||
@@ -94,6 +110,17 @@ export function Timeline({
|
|||||||
/** How the bars are stacked: a lane per game, or one deadline queue. */
|
/** How the bars are stacked: a lane per game, or one deadline queue. */
|
||||||
group: TimelineGroup;
|
group: TimelineGroup;
|
||||||
onGroup: (group: TimelineGroup) => void;
|
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;
|
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
|
||||||
@@ -105,8 +132,14 @@ export function Timeline({
|
|||||||
const gameMeta = useGameMeta();
|
const gameMeta = useGameMeta();
|
||||||
const scroller = useRef<HTMLDivElement>(null);
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
|
// Held back rather than filtered away by the caller, so the control can say
|
||||||
const starts = rows.map((r) => r.clock.startsMs);
|
// 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 { min, max } = boardWindow(starts, ends, now);
|
||||||
const totalDays = Math.ceil((max - min) / DAY);
|
const totalDays = Math.ceil((max - min) / DAY);
|
||||||
const chartWidth = totalDays * dayWidth;
|
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 months = monthBoundaries(min, max);
|
||||||
const weeks = weekBoundaries(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
|
{/* 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
|
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 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} />
|
<StackControl value={group} onChange={onGroup} />
|
||||||
|
<UpcomingControl
|
||||||
|
on={showUpcoming}
|
||||||
|
waiting={waiting.length}
|
||||||
|
onChange={onShowUpcoming}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -209,6 +250,18 @@ export function Timeline({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
ref={scroller}
|
ref={scroller}
|
||||||
/*
|
/*
|
||||||
@@ -248,6 +301,29 @@ export function Timeline({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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. */}
|
{/* 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">
|
<div className="sticky top-0 z-30 h-10 border-b border-hairline bg-ground/95 backdrop-blur">
|
||||||
{months.map((m) => (
|
{months.map((m) => (
|
||||||
@@ -272,7 +348,9 @@ export function Timeline({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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) => {
|
{lanes.map((lane) => {
|
||||||
const heading = lane.game === null ? null : gameMeta(lane.game);
|
const heading = lane.game === null ? null : gameMeta(lane.game);
|
||||||
return (
|
return (
|
||||||
@@ -296,6 +374,7 @@ export function Timeline({
|
|||||||
{lane.rows.map(({ event, clock }) => {
|
{lane.rows.map(({ event, clock }) => {
|
||||||
const game = gameMeta(event.game);
|
const game = gameMeta(event.game);
|
||||||
const unknownEnd = clock.endsMs === null;
|
const unknownEnd = clock.endsMs === null;
|
||||||
|
const notStarted = clock.upcoming;
|
||||||
// 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
|
||||||
// in practice bars show their real start and the fade is
|
// 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
|
// it carries, and colour alone is not an answer once
|
||||||
// thirteen games share one stack.
|
// thirteen games share one stack.
|
||||||
title={
|
title={
|
||||||
heading === null
|
(heading === null
|
||||||
? `${game.name} — ${event.title}`
|
? `${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 ${
|
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"
|
||||||
@@ -325,12 +407,20 @@ export function Timeline({
|
|||||||
style={{
|
style={{
|
||||||
marginLeft: left,
|
marginLeft: left,
|
||||||
width,
|
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
|
// 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
|
borderLeft: clippedStart
|
||||||
? undefined
|
? undefined
|
||||||
: `3px solid ${game.hue}`,
|
: `3px ${notStarted ? "dashed" : "solid"} ${game.hue}`,
|
||||||
// Frayed right = end unannounced; faded left =
|
// Frayed right = end unannounced; faded left =
|
||||||
// started before the window. Both are honest about
|
// started before the window. Both are honest about
|
||||||
// what is not shown.
|
// what is not shown.
|
||||||
@@ -353,6 +443,17 @@ export function Timeline({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="min-w-0 truncate">{event.title}</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>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -369,6 +470,7 @@ export function Timeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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.
|
* One step of the scale control.
|
||||||
*
|
*
|
||||||
@@ -533,3 +679,74 @@ function monthBoundaries(min: number, max: number) {
|
|||||||
}
|
}
|
||||||
return out;
|
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
@@ -1,7 +1,12 @@
|
|||||||
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, 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 { 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";
|
||||||
@@ -57,6 +62,44 @@ function row(title: string, game: GameId, endsInHours: number | null) {
|
|||||||
return { event, clock: clockFor(event, "europe", NOW) };
|
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", () => {
|
describe("NextUp", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
row("Closing Ceremony", "genshin", 6),
|
row("Closing Ceremony", "genshin", 6),
|
||||||
@@ -217,6 +260,8 @@ describe("Timeline stacking", () => {
|
|||||||
onZoom={() => {}}
|
onZoom={() => {}}
|
||||||
group={group}
|
group={group}
|
||||||
onGroup={() => {}}
|
onGroup={() => {}}
|
||||||
|
showUpcoming={false}
|
||||||
|
onShowUpcoming={() => {}}
|
||||||
onOpen={() => {}}
|
onOpen={() => {}}
|
||||||
isDone={() => false}
|
isDone={() => false}
|
||||||
/>,
|
/>,
|
||||||
@@ -252,3 +297,151 @@ describe("Timeline stacking", () => {
|
|||||||
expect(pressed("ending")).toBe("Ending soonest");
|
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("–");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user