fix: let the calendar scroll back

Clamping the window to now made the past unreachable — you could not see when
a running event began. Rendering from the earliest start instead buried today
off-screen, which is what the clamp was fixing.

The window now covers both and the container opens scrolled to today, so the
default view is unchanged and history is a scroll away. It reaches a week
past the oldest running event, so the range stays bounded by what is actually
live. Nothing is clipped at the left edge any more.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:43:47 +02:00
co-authored by Claude Opus 5
parent 0efcfc52f0
commit 5ea5d8292f
+37 -14
View File
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import { gameMeta } from "../../shared/games.ts"; import { gameMeta } from "../../shared/games.ts";
import type { GameId } from "../../shared/schema.ts"; import type { GameId } from "../../shared/schema.ts";
import { DAY } from "../../shared/time.ts"; import { DAY } from "../../shared/time.ts";
@@ -12,6 +13,12 @@ const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at onc
*/ */
const HALF_DAY_LEAD = 12 * 60 * 60 * 1000; const HALF_DAY_LEAD = 12 * 60 * 60 * 1000;
/**
* How far back the view can be scrolled beyond the oldest running event, so
* "when did this start?" is answerable without the window being unbounded.
*/
const PAST_LEAD = 7 * DAY;
/** /**
* 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.
* *
@@ -30,6 +37,31 @@ export function Timeline({
onOpen: (id: string) => void; onOpen: (id: string) => void;
completions: Record<string, unknown>; completions: Record<string, unknown>;
}) { }) {
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);
// 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;
// Where "now" sits, so the container can be scrolled there on open.
const nowOffset = ((now - HALF_DAY_LEAD - min) / DAY) * DAY_WIDTH;
const totalDays = Math.ceil((max - min) / DAY);
const width = totalDays * DAY_WIDTH;
const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH;
// Open at today rather than at the far past. Keyed on the rounded offset so
// it runs when the range changes, not every second — re-scrolling on each
// tick would fight the reader's own scrolling.
const openAt = Math.round(nowOffset);
useEffect(() => {
scroller.current?.scrollTo({ left: openAt, behavior: "instant" });
}, [openAt]);
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<p className="px-4 py-10 text-sm text-muted"> <p className="px-4 py-10 text-sm text-muted">
@@ -38,17 +70,6 @@ export function Timeline({
); );
} }
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
// The window opens at now. Events that began earlier are clipped at the left
// edge rather than pushing the view back weeks — what already happened is not
// what the reader is here for, and starting at the earliest start buries
// today off-screen.
const min = now - HALF_DAY_LEAD;
const max = Math.max(...ends, now) + 2 * DAY;
const totalDays = Math.ceil((max - min) / DAY);
const width = totalDays * DAY_WIDTH;
const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH;
const byGame = new Map<GameId, RowEvent[]>(); const byGame = new Map<GameId, RowEvent[]>();
for (const row of rows) { for (const row of rows) {
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]); byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
@@ -57,7 +78,7 @@ export function Timeline({
const monthTicks = monthBoundaries(min, max); const monthTicks = monthBoundaries(min, max);
return ( return (
<div className="scroll-x"> <div ref={scroller} className="scroll-x">
<div style={{ width, minWidth: "100%" }} className="relative px-4 pb-8 pt-3"> <div style={{ width, minWidth: "100%" }} className="relative px-4 pb-8 pt-3">
{/* Month rule, so a bar's absolute position means something. */} {/* Month rule, so a bar's absolute position means something. */}
<div className="relative mb-3 h-4 border-b border-hairline"> <div className="relative mb-3 h-4 border-b border-hairline">
@@ -91,8 +112,10 @@ export function Timeline({
<div className="relative h-auto space-y-1"> <div className="relative h-auto space-y-1">
{events.map(({ event, clock }) => { {events.map(({ event, clock }) => {
const unknownEnd = clock.endsMs === null; const unknownEnd = clock.endsMs === null;
// Already running when the view opens: clip to the edge and // Only clipped if it began before the rendered window,
// say so, the same way an unknown end frays on the right. // which now reaches a week past the oldest running event —
// so in practice bars show their real start and the fade is
// reserved for genuinely truncated ones.
const clippedStart = clock.startsMs < min; const clippedStart = clock.startsMs < min;
const left = Math.max(x(clock.startsMs), 0); const left = Math.max(x(clock.startsMs), 0);
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY); const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);