diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index a5a8abb..14d2e1a 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -6,6 +6,12 @@ import { URGENCY_COLOR } from "./Meter.tsx"; const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at once +/** + * A sliver of time before now, so the "now" rule reads as a line in the view + * rather than merging with the border. + */ +const HALF_DAY_LEAD = 12 * 60 * 60 * 1000; + /** * One lane per game, bars spanning start→end, today pinned as a rule. * @@ -32,9 +38,12 @@ export function Timeline({ ); } - 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; + // 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; @@ -81,8 +90,11 @@ export function Timeline({

{events.map(({ event, clock }) => { - const left = x(clock.startsMs); const unknownEnd = clock.endsMs === null; + // Already running when the view opens: clip to the edge and + // say so, the same way an unknown end frays on the right. + const clippedStart = clock.startsMs < min; + const left = Math.max(x(clock.startsMs), 0); const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY); const done = completions[event.id] !== undefined; return ( @@ -98,12 +110,14 @@ export function Timeline({ 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, + // No start edge to draw when the bar begins before + // the view does. + borderLeft: clippedStart + ? undefined + : `2px solid ${game.hue}`, + // Frayed right = end unannounced; faded left = started + // before now. Both are honest about what is not shown. + maskImage: edgeMask(clippedStart, unknownEnd), color: "var(--color-ink)", }} > @@ -126,18 +140,36 @@ export function Timeline({ ); } +/** + * 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. + */ +function edgeMask(clippedStart: boolean, unknownEnd: boolean): string | undefined { + if (clippedStart && unknownEnd) { + return "linear-gradient(90deg, transparent 0%, #000 14%, #000 60%, transparent 100%)"; + } + if (clippedStart) return "linear-gradient(90deg, transparent 0%, #000 14%)"; + if (unknownEnd) return "linear-gradient(90deg, #000 60%, transparent 100%)"; + return undefined; +} + function monthBoundaries(min: number, max: number) { - const out: Array<{ ms: number; label: string }> = []; + const short = (ms: number) => + new Date(ms).toLocaleDateString(undefined, { month: "short" }); + + // The window opens mid-month, so the first real boundary can be weeks away. + // Label the left edge with the current month or the opening stretch has no + // date context at all. + const out: Array<{ ms: number; label: string }> = [ + { ms: min, label: short(min) }, + ]; + const d = new Date(min); d.setUTCDate(1); d.setUTCHours(0, 0, 0, 0); + d.setUTCMonth(d.getUTCMonth() + 1); while (d.getTime() <= max) { - if (d.getTime() >= min) { - out.push({ - ms: d.getTime(), - label: d.toLocaleDateString(undefined, { month: "short" }), - }); - } + out.push({ ms: d.getTime(), label: short(d.getTime()) }); d.setUTCMonth(d.getUTCMonth() + 1); } return out;