feat(timeline): stop two months back, and give the bars room
Two changes to how the board reads. The window was drawn from the earliest start, so one long-running event — a standing login campaign can have been going half a year — bought months of empty calendar that nobody scrolls back through and that pushed every other bar off to the right. It now floors at two months, a patch cycle and a half: far enough that a running event's start is usually still on the board, past the point where the answer changes what anyone does today. A bar older than the board keeps its faded left edge rather than being redrawn as though it started there. The bars themselves were 28px with 4px between them, which read as a stack of hairlines rather than a schedule. They are 36px now, with more padding, more air between events, and more between lanes. `boardWindow` comes out of the component while it is being changed: it decides what a reader can and cannot see, which is worth a test rather than a rendering. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4435347299
commit
c4f6551844
@@ -80,6 +80,12 @@ calendar on screen, and nothing left saying which day, whose game, or which even
|
||||
six-week bar starts weeks off-screen, so a name that rides off with its own start date leaves a
|
||||
coloured rectangle behind.
|
||||
|
||||
**The board draws at most two months of past.** A standing login campaign can have been running for
|
||||
half a year, and drawing from the earliest start bought months of empty calendar that nobody scrolls
|
||||
back through and that pushed every other bar off to the right. An event older than the board keeps
|
||||
its faded left edge rather than being redrawn as though it started at the edge — the same honesty
|
||||
the frayed right edge carries for an unannounced end.
|
||||
|
||||
**F2 — Ends-soonest list.**
|
||||
A flat list of all *currently running* events sorted ascending by end date, with a relative
|
||||
countdown ("ends in 2 days", "ends in 4 hours"). Under 24 hours, the row is emphasized. This is the
|
||||
|
||||
@@ -28,6 +28,21 @@ const HALF_DAY_LEAD = 12 * 60 * 60 * 1000;
|
||||
*/
|
||||
const PAST_LEAD = 7 * DAY;
|
||||
|
||||
/**
|
||||
* The oldest date the board will draw, however long an event has been running.
|
||||
*
|
||||
* A standing login campaign can have started half a year ago, and the window
|
||||
* was drawn from the earliest start — so one such event bought months of empty
|
||||
* calendar to the left of everything else, which nobody scrolls back through
|
||||
* and which the reader pays for in every bar being pushed off to the right.
|
||||
* Two months is a patch cycle and a half: enough that a running event's start
|
||||
* is usually still on the board, and past the point where the answer stops
|
||||
* changing what anyone does today. A bar that begins before this keeps its
|
||||
* faded left edge, which is the same honesty the mask already carried — the
|
||||
* event is older than the board, not newly started at its edge.
|
||||
*/
|
||||
const PAST_LIMIT = 60 * DAY;
|
||||
|
||||
/** Where the now rule sits when the board opens: a little in from the edge. */
|
||||
const OPEN_INSET = 28;
|
||||
|
||||
@@ -65,13 +80,7 @@ export function Timeline({
|
||||
|
||||
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
|
||||
const starts = rows.map((r) => r.clock.startsMs);
|
||||
|
||||
// The window covers the past too, so a reader can scroll back to see when a
|
||||
// running event began — but it *opens* scrolled to now, because that is what
|
||||
// they came for. Rendering from the earliest start alone buried today
|
||||
// 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;
|
||||
const { min, max } = boardWindow(starts, ends, now);
|
||||
const totalDays = Math.ceil((max - min) / DAY);
|
||||
const chartWidth = totalDays * DAY_WIDTH;
|
||||
/** One coordinate space for everything: bars, gridlines and the now rule. */
|
||||
@@ -183,7 +192,7 @@ export function Timeline({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 pb-8 pt-4">
|
||||
<div className="space-y-7 pb-10 pt-5">
|
||||
{[...byGame.entries()].map(([gameId, events]) => {
|
||||
const game = gameMeta(gameId);
|
||||
return (
|
||||
@@ -192,14 +201,14 @@ export function Timeline({
|
||||
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]"
|
||||
className="eyebrow sticky left-0 z-20 mb-2.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">
|
||||
<div className="relative space-y-2">
|
||||
{events.map(({ event, clock }) => {
|
||||
const unknownEnd = clock.endsMs === null;
|
||||
// Only clipped if it began before the rendered window,
|
||||
@@ -216,18 +225,18 @@ export function Timeline({
|
||||
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 ${
|
||||
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"
|
||||
}`}
|
||||
style={{
|
||||
marginLeft: left,
|
||||
width: Math.max(right - left, 26),
|
||||
width: Math.max(right - left, 34),
|
||||
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}`,
|
||||
: `3px solid ${game.hue}`,
|
||||
// Frayed right = end unannounced; faded left =
|
||||
// started before the window. Both are honest about
|
||||
// what is not shown.
|
||||
@@ -262,6 +271,31 @@ export function Timeline({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The span of time the board draws.
|
||||
*
|
||||
* It covers the past too, so a reader can scroll back to see when a running
|
||||
* event began — but it *opens* scrolled to now, because that is what they came
|
||||
* for. Rendering from the earliest start alone buried today off-screen; clamping
|
||||
* to now made the past unreachable; and an event that has been running for half
|
||||
* a year bought months of empty calendar that pushed everything else right.
|
||||
* `PAST_LIMIT` is the floor under that last case.
|
||||
*
|
||||
* Pure, and separate from the component, because it decides what a reader can
|
||||
* and cannot see — which is worth a test rather than a rendering.
|
||||
*/
|
||||
export function boardWindow(
|
||||
starts: readonly number[],
|
||||
ends: readonly number[],
|
||||
now: number,
|
||||
): { min: number; max: number } {
|
||||
const earliest = Math.min(...starts, now) - PAST_LEAD;
|
||||
return {
|
||||
min: Math.max(earliest, now - PAST_LIMIT),
|
||||
max: Math.max(...ends, now) + 2 * DAY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade a bar's edge where the truth extends past what is drawn: the left when
|
||||
* the event began before the view opens, the right when its end is unannounced.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { NextUp } from "../src/client/components/NextUp.tsx";
|
||||
import { boardWindow } from "../src/client/components/Timeline.tsx";
|
||||
import { Welcome } from "../src/client/components/Welcome.tsx";
|
||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||
import { metaFor } from "../src/shared/games.ts";
|
||||
@@ -120,3 +121,33 @@ describe("Welcome (first run)", () => {
|
||||
expect(html()).toContain('aria-checked="true"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Timeline window", () => {
|
||||
const DAY = 86_400_000;
|
||||
|
||||
test("reaches a week past the oldest running event", () => {
|
||||
// So "when did this start?" is answerable without the reader hunting for
|
||||
// an edge, and so a bar that began days ago shows its real start.
|
||||
const started = NOW - 20 * DAY;
|
||||
const { min } = boardWindow([started], [NOW + 5 * DAY], NOW);
|
||||
expect(min).toBe(started - 7 * DAY);
|
||||
});
|
||||
|
||||
test("stops two months back however long something has been running", () => {
|
||||
// A standing login campaign can have started half a year ago. Drawing from
|
||||
// its start bought months of empty calendar that nobody scrolls through and
|
||||
// pushed every other bar off to the right.
|
||||
const ancient = NOW - 200 * DAY;
|
||||
const { min } = boardWindow([ancient, NOW - 3 * DAY], [NOW + 5 * DAY], NOW);
|
||||
expect(min).toBe(NOW - 60 * DAY);
|
||||
// The bar is then older than the board, which is what the faded left edge
|
||||
// says — it must not be redrawn as though it started at the edge.
|
||||
expect(ancient).toBeLessThan(min);
|
||||
});
|
||||
|
||||
test("today is on the board even when everything is still to come", () => {
|
||||
const { min, max } = boardWindow([NOW + 30 * DAY], [NOW + 40 * DAY], NOW);
|
||||
expect(min).toBeLessThan(NOW);
|
||||
expect(max).toBeGreaterThan(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user