feat: add the Event Clock interface

Opens on the single event closest to expiring, at a size nothing else
competes with — the reader arrives with one question. Below it, live events
ordered by what ends soonest, then a quiet timeline view with one lane per
game.

Countdowns use tabular figures so ticking never reflows the row.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:28:45 +02:00
co-authored by Claude Opus 5
parent ec8cc97c1a
commit b1d18eaf59
7 changed files with 885 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
import { gameMeta } from "../../shared/games.ts";
import type { GameId } from "../../shared/schema.ts";
import { DAY } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { URGENCY_COLOR } from "./Meter.tsx";
const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at once
/**
* One lane per game, bars spanning start→end, today pinned as a rule.
*
* The quiet view. The ending-soon list carries the page's boldness, so this
* stays flat and legible: no gradients, no rounded chrome, just position and
* length doing the work.
*/
export function Timeline({
rows,
now,
onOpen,
completions,
}: {
rows: RowEvent[];
now: number;
onOpen: (id: string) => void;
completions: Record<string, unknown>;
}) {
if (rows.length === 0) {
return (
<p className="px-4 py-10 text-sm text-muted">
Nothing to plot. Switch a game back on to see its schedule.
</p>
);
}
const starts = rows.map((r) => r.clock.startsMs);
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
const min = Math.min(...starts, now) - 2 * DAY;
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[]>();
for (const row of rows) {
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
}
const monthTicks = monthBoundaries(min, max);
return (
<div className="scroll-x">
<div style={{ width, minWidth: "100%" }} className="relative px-4 pb-8 pt-3">
{/* Month rule, so a bar's absolute position means something. */}
<div className="relative mb-3 h-4 border-b border-hairline">
{monthTicks.map((t) => (
<span
key={t.ms}
className="eyebrow absolute -translate-x-px whitespace-nowrap border-l border-hairline pl-1"
style={{ left: x(t.ms) }}
>
{t.label}
</span>
))}
</div>
<div
aria-hidden
className="absolute bottom-8 top-8 z-10 w-px bg-critical/70"
style={{ left: x(now) + 16 }}
>
<span className="eyebrow absolute -top-4 left-1 text-critical">now</span>
</div>
<div className="space-y-4">
{[...byGame.entries()].map(([gameId, events]) => {
const game = gameMeta(gameId);
return (
<div key={gameId}>
<p className="eyebrow mb-1.5" style={{ color: game.hue }}>
{game.short}
</p>
<div className="relative h-auto space-y-1">
{events.map(({ event, clock }) => {
const left = x(clock.startsMs);
const unknownEnd = clock.endsMs === null;
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
const done = completions[event.id] !== undefined;
return (
<button
key={event.id}
type="button"
onClick={() => onOpen(event.id)}
title={event.title}
className={`relative flex h-6 items-center overflow-hidden rounded-[3px] px-1.5 text-left text-[0.6875rem] font-medium transition-opacity hover:opacity-100 ${
done ? "opacity-35" : "opacity-90"
}`}
style={{
marginLeft: left,
width: Math.max(right - left, 22),
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
borderLeft: `2px solid ${game.hue}`,
// A frayed right edge says the end is unannounced —
// visually distinct from an event ending far away.
maskImage: unknownEnd
? "linear-gradient(90deg, #000 60%, transparent 100%)"
: undefined,
color: "var(--color-ink)",
}}
>
<span className="truncate">{event.title}</span>
<span
aria-hidden
className="ml-auto size-1.5 shrink-0 rounded-full"
style={{ background: URGENCY_COLOR[clock.urgency] }}
/>
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
function monthBoundaries(min: number, max: number) {
const out: Array<{ ms: number; label: string }> = [];
const d = new Date(min);
d.setUTCDate(1);
d.setUTCHours(0, 0, 0, 0);
while (d.getTime() <= max) {
if (d.getTime() >= min) {
out.push({
ms: d.getTime(),
label: d.toLocaleDateString(undefined, { month: "short" }),
});
}
d.setUTCMonth(d.getUTCMonth() + 1);
}
return out;
}