diff --git a/docs/PRD.md b/docs/PRD.md index 9610438..6aff88c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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 diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index 3f10343..98f2a83 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -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({ ))} -
+
{[...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. */}

{game.short}

-
+
{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. diff --git a/test/views.test.tsx b/test/views.test.tsx index 5d0989d..31116b6 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -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); + }); +});