Two halves of one reading. The board answers "how does the time I am in lay out?", and plotting every lane's next patch stretched the window weeks past today and squeezed the running bars down to nothing — so it holds them back, and holds them back itself rather than being handed a shorter list, because the control has to say *how many*. A board that quietly withheld a third of the schedule is indistinguishable from a quiet fortnight, which is the wrong thing for this app to imply by accident. A board with nothing left running says so and points at the control instead of reading as an empty calendar. Switched on, a bar drawn right of the now rule is only implicitly in the future, and implicitly is not a standard we hold anywhere else a date is involved. Gacha schedules are not a smooth stream of starts either — a patch ships six things at once — so the unit is the clump: a dashed rule per group of starts, labelled in words. Clumps merge by distance on screen rather than by calendar, since a week apart is one mark at six pixels a day and two at a hundred and eight, and a merged one states the span it covers rather than claiming a date the board is not ruling at. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
753 lines
29 KiB
TypeScript
753 lines
29 KiB
TypeScript
import { useLayoutEffect, useRef } from "react";
|
||
import { useGameMeta } from "../state/gameMeta.tsx";
|
||
import { DAY } from "../../shared/time.ts";
|
||
import type { RowEvent } from "./EventRow.tsx";
|
||
import { URGENCY_COLOR } from "./Meter.tsx";
|
||
import {
|
||
timelineLanes,
|
||
TIMELINE_GROUPS,
|
||
type TimelineGroup,
|
||
} from "../state/lanes.ts";
|
||
import { canStep, stepDayWidth, weekLabelStep } from "../state/zoom.ts";
|
||
|
||
/**
|
||
* How far in from the left edge of the board a pinned label sits.
|
||
*
|
||
* Both the lane names and the event names stick here rather than riding off
|
||
* with their own start dates. A six-week event begins weeks off-screen, and a
|
||
* bar whose name scrolled away with its start date is a coloured rectangle.
|
||
*/
|
||
const PIN = 8;
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/** The narrowest a bar is drawn, so a two-day event is still a target. */
|
||
const MIN_BAR = 34;
|
||
|
||
/**
|
||
* How wide a bar has to be before the merged board tags it with its game.
|
||
*
|
||
* The tag is what replaces the lane heading, so it wants to be on every bar —
|
||
* but a bar narrower than this has no room for the tag *and* a title, and what
|
||
* a reader gets is a truncated game name reading as a broken word. Below the
|
||
* threshold the hue and the tooltip carry it, which is what a two-day bar could
|
||
* say about itself anyway.
|
||
*/
|
||
const TAG_FROM = 96;
|
||
|
||
/**
|
||
* How far apart two start markers have to be before both are drawn.
|
||
*
|
||
* Games ship on their own patch days, so at a wide scale several clumps land
|
||
* within a few pixels of each other and their labels overlap into an unreadable
|
||
* smear. Below this the later clump is folded into the earlier marker's count,
|
||
* which is the honest reading anyway: "eleven things start around here".
|
||
*
|
||
* Sized to the widest label the marker draws rather than to a gridline, since
|
||
* what collides is the text and not the rule. A merged marker says the span it
|
||
* covers, so nothing is claimed about a date the board is not ruling.
|
||
*/
|
||
const MARKER_GAP = 104;
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* It is a board rather than a stretch of page — its own pane, scrolling in both
|
||
* directions, with the date axis pinned to the top and every name pinned to the
|
||
* left. All three used to scroll away together, which is what made a wide
|
||
* window worse rather than better: more calendar on screen, and nothing left
|
||
* saying which day, whose game, or which event you were looking at.
|
||
*/
|
||
export function Timeline({
|
||
rows,
|
||
now,
|
||
dayWidth,
|
||
onZoom,
|
||
group,
|
||
onGroup,
|
||
showUpcoming,
|
||
onShowUpcoming,
|
||
onOpen,
|
||
isDone,
|
||
}: {
|
||
rows: RowEvent[];
|
||
now: number;
|
||
/** How wide one day is, in px. Snapped to the ladder in `state/zoom.ts`. */
|
||
dayWidth: number;
|
||
onZoom: (dayWidth: number) => void;
|
||
/** How the bars are stacked: a lane per game, or one deadline queue. */
|
||
group: TimelineGroup;
|
||
onGroup: (group: TimelineGroup) => void;
|
||
/**
|
||
* Whether events that have not started yet are plotted.
|
||
*
|
||
* Off by default (`prefs.timelineUpcoming`). The board is asked "how does the
|
||
* time I am in lay out?", and every lane has a next patch queued behind it —
|
||
* plotting those unasked stretches the window weeks past today and squeezes
|
||
* the running bars the reader came for. The count is on the control, so what
|
||
* is held back is stated rather than merely absent.
|
||
*/
|
||
showUpcoming: boolean;
|
||
onShowUpcoming: (showUpcoming: boolean) => void;
|
||
onOpen: (id: string) => void;
|
||
/**
|
||
* Asked rather than derived from the progress store: an entry exists there
|
||
* the moment a reader records an effort or a note, and dimming a bar for
|
||
* that would say "finished" about something they have not started.
|
||
*/
|
||
isDone: (id: string) => boolean;
|
||
}) {
|
||
const gameMeta = useGameMeta();
|
||
const scroller = useRef<HTMLDivElement>(null);
|
||
|
||
// Held back rather than filtered away by the caller, so the control can say
|
||
// how many there are — a board that silently drops a third of the schedule is
|
||
// the same failure as a stale date, arriving as an absence.
|
||
const waiting = rows.filter((r) => r.clock.upcoming);
|
||
const plotted = showUpcoming ? rows : rows.filter((r) => !r.clock.upcoming);
|
||
|
||
const ends = plotted.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
|
||
const starts = plotted.map((r) => r.clock.startsMs);
|
||
const { min, max } = boardWindow(starts, ends, now);
|
||
const totalDays = Math.ceil((max - min) / DAY);
|
||
const chartWidth = totalDays * dayWidth;
|
||
/** One coordinate space for everything: bars, gridlines and the now rule. */
|
||
const x = (ms: number) => ((ms - min) / DAY) * dayWidth;
|
||
|
||
// Open at today rather than at the far past, with a little of the past week
|
||
// still on screen — an event that began three days ago is context, not
|
||
// history. Keyed on the rounded offset so it runs when the range changes,
|
||
// not every second: re-scrolling on each tick would fight the reader.
|
||
const openAt = Math.round(Math.max(0, x(now - HALF_DAY_LEAD) - OPEN_INSET));
|
||
const jumpToNow = (behavior: ScrollBehavior) =>
|
||
scroller.current?.scrollTo({ left: openAt, behavior });
|
||
|
||
/**
|
||
* A moment in time to hold still through the next re-render, and where in the
|
||
* pane to hold it. Set when the reader zooms: rescaling around the left edge
|
||
* of the scroll area would throw whatever they were reading off the screen,
|
||
* and re-opening at today would undo the scrolling they did to get there.
|
||
*/
|
||
const hold = useRef<{ ms: number; px: number } | null>(null);
|
||
|
||
const zoom = (by: 1 | -1) => {
|
||
const el = scroller.current;
|
||
if (el !== null) {
|
||
// The middle of the view is what a reader is looking at, so that is what
|
||
// stays put.
|
||
const px = el.clientWidth / 2;
|
||
hold.current = { ms: min + ((el.scrollLeft + px) / dayWidth) * DAY, px };
|
||
}
|
||
onZoom(stepDayWidth(dayWidth, by));
|
||
};
|
||
|
||
// Before paint, so a zoom never shows a frame at the wrong offset.
|
||
useLayoutEffect(() => {
|
||
const el = scroller.current;
|
||
if (el === null) return;
|
||
const anchor = hold.current;
|
||
if (anchor !== null) {
|
||
hold.current = null;
|
||
el.scrollLeft = Math.max(0, x(anchor.ms) - anchor.px);
|
||
return;
|
||
}
|
||
// Open at today rather than at the far past: it is what they came for.
|
||
// 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.
|
||
el.scrollTo({ left: openAt, behavior: "instant" });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [openAt]);
|
||
|
||
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 lanes = timelineLanes(plotted, group);
|
||
const marks = startMarkers(plotted, x);
|
||
|
||
const months = monthBoundaries(min, max);
|
||
const weeks = weekBoundaries(min, max);
|
||
// Every Monday is right at the default scale and illegible at the widest zoom
|
||
// out, where the dates would sit on top of each other.
|
||
const labelEvery = weekLabelStep(dayWidth);
|
||
|
||
return (
|
||
<>
|
||
{/* The board's own header. The jump control lives out here rather than
|
||
floating over the chart: pinned inside, it would sit on top of the
|
||
calendar and cover the very dates it sends you back to. */}
|
||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-hairline px-4 py-2.5">
|
||
<div className="flex items-center gap-2">
|
||
<StackControl value={group} onChange={onGroup} />
|
||
<UpcomingControl
|
||
on={showUpcoming}
|
||
waiting={waiting.length}
|
||
onChange={onShowUpcoming}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div role="group" aria-label="Scale" className="flex items-center gap-1">
|
||
<ScaleButton
|
||
label="Show a longer stretch of time"
|
||
disabled={!canStep(dayWidth, -1)}
|
||
onClick={() => zoom(-1)}
|
||
>
|
||
−
|
||
</ScaleButton>
|
||
<ScaleButton
|
||
label="Show a shorter stretch of time in more detail"
|
||
disabled={!canStep(dayWidth, 1)}
|
||
onClick={() => zoom(1)}
|
||
>
|
||
+
|
||
</ScaleButton>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => jumpToNow("smooth")}
|
||
className="text-[0.6875rem] font-medium text-faint transition-colors duration-150 hover:text-ink"
|
||
>
|
||
Jump to today
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{plotted.length === 0 ? (
|
||
/* Not the same emptiness as no rows at all: everything the reader can
|
||
see is still ahead of them, and the board is hiding it on purpose.
|
||
Say which, or the control above reads as broken. */
|
||
<p className="px-4 py-10 text-sm leading-relaxed text-muted">
|
||
Nothing is running right now.{" "}
|
||
{waiting.length === 1
|
||
? "One event has not started yet"
|
||
: `${waiting.length} events have not started yet`}{" "}
|
||
— switch “Not started” on to see when they begin.
|
||
</p>
|
||
) : (
|
||
<div
|
||
ref={scroller}
|
||
/*
|
||
* The pane scrolls, not the page — which is what lets the axis stay
|
||
* put. Capped rather than fixed: three lanes take the height they need
|
||
* and nothing scrolls vertically at all.
|
||
*/
|
||
className="scroll-pane relative max-h-[72vh] overflow-auto overscroll-x-contain"
|
||
>
|
||
<div className="relative" style={{ width: chartWidth, minWidth: "100%" }}>
|
||
{/* Gridlines first, so everything else paints over them. */}
|
||
<div aria-hidden className="pointer-events-none absolute inset-0">
|
||
{weeks.map((ms) => (
|
||
<span
|
||
key={ms}
|
||
className="absolute bottom-0 top-10 w-px bg-hairline/40"
|
||
style={{ left: x(ms) }}
|
||
/>
|
||
))}
|
||
{months.slice(1).map((m) => (
|
||
<span
|
||
key={m.ms}
|
||
className="absolute bottom-0 top-10 w-px bg-hairline"
|
||
style={{ left: x(m.ms) }}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Now: the one rule that has to be findable from anywhere. */}
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none absolute bottom-0 top-10 z-10 w-px bg-critical/80"
|
||
style={{ left: x(now) }}
|
||
>
|
||
<span className="eyebrow absolute top-1 -translate-x-1/2 rounded-[3px] bg-critical px-1 py-px text-[0.5625rem] leading-none text-ground">
|
||
now
|
||
</span>
|
||
</div>
|
||
|
||
{/* Where the not-yet-started events begin, said in words.
|
||
A bar drawn to the right of the now rule is the only thing saying
|
||
"this has not started", and that reads as a fact about the layout
|
||
rather than about the event — so each clump of starts gets a
|
||
dashed rule and a label counting what opens there. Below the now
|
||
rule in the stack: today is the one mark that has to win. */}
|
||
{marks.map((mark) => (
|
||
<div
|
||
key={mark.ms}
|
||
aria-hidden
|
||
className="pointer-events-none absolute bottom-0 top-10 z-[5] w-px border-l border-dashed border-faint/60"
|
||
style={{ left: x(mark.ms) }}
|
||
>
|
||
{/* Its own band under the `now` chip rather than beside it: at a
|
||
wide scale the first clump is a few pixels from today, and
|
||
sharing a line cost the label its first character to a chip
|
||
that is deliberately drawn on top of everything. */}
|
||
<span className="tnum absolute top-5 whitespace-nowrap rounded-[3px] border border-hairline bg-ground px-1 py-px text-[0.5625rem] leading-none text-muted">
|
||
{markerLabel(mark)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* The axis: months above, week dates below, pinned to the top. */}
|
||
<div className="sticky top-0 z-30 h-10 border-b border-hairline bg-ground/95 backdrop-blur">
|
||
{months.map((m) => (
|
||
<span
|
||
key={m.ms}
|
||
className="eyebrow absolute top-1 whitespace-nowrap pl-1.5 text-faint"
|
||
style={{ left: x(m.ms) }}
|
||
>
|
||
{m.label}
|
||
</span>
|
||
))}
|
||
{weeks.map((ms, i) =>
|
||
i % labelEvery === 0 ? (
|
||
<span
|
||
key={ms}
|
||
className="tnum absolute bottom-1 whitespace-nowrap pl-1.5 text-[0.625rem] leading-none text-faint"
|
||
style={{ left: x(ms) }}
|
||
>
|
||
{dayLabel(ms)}
|
||
</span>
|
||
) : null,
|
||
)}
|
||
</div>
|
||
|
||
{/* The top padding is the marker band's room — see the start markers
|
||
above, which hang in it. */}
|
||
<div className="space-y-7 pb-10 pt-9">
|
||
{lanes.map((lane) => {
|
||
const heading = lane.game === null ? null : gameMeta(lane.game);
|
||
return (
|
||
<div key={lane.id}>
|
||
{/* On its own line and pinned to the left edge, so the lane
|
||
keeps its name at any scroll position without a frozen
|
||
column standing on top of the calendar. Absent on the
|
||
merged board, where there is no one game to name — each
|
||
bar carries its own instead. */}
|
||
{heading !== null && (
|
||
<p
|
||
className="eyebrow sticky left-0 z-20 mb-2.5 w-fit bg-ground pr-2 text-[0.625rem]"
|
||
style={{ color: heading.hue, paddingLeft: PIN }}
|
||
title={heading.name}
|
||
>
|
||
{heading.short}
|
||
</p>
|
||
)}
|
||
|
||
<div className="relative space-y-2">
|
||
{lane.rows.map(({ event, clock }) => {
|
||
const game = gameMeta(event.game);
|
||
const unknownEnd = clock.endsMs === null;
|
||
const notStarted = clock.upcoming;
|
||
// Only clipped if it began before the rendered window,
|
||
// which 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 left = Math.max(x(clock.startsMs), 0);
|
||
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
|
||
const width = Math.max(right - left, MIN_BAR);
|
||
const done = isDone(event.id);
|
||
return (
|
||
<button
|
||
key={event.id}
|
||
type="button"
|
||
onClick={() => onOpen(event.id)}
|
||
// The game is in the tooltip on the merged board
|
||
// because the bar can be too narrow to show the tag
|
||
// it carries, and colour alone is not an answer once
|
||
// thirteen games share one stack.
|
||
title={
|
||
(heading === null
|
||
? `${game.name} — ${event.title}`
|
||
: event.title) +
|
||
(notStarted
|
||
? ` — not started yet, begins ${dayLabel(clock.startsMs)}`
|
||
: "")
|
||
}
|
||
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,
|
||
// Thinner wash on an event nobody can play yet, so
|
||
// a glance at the board separates what is running
|
||
// from what is merely scheduled without reading a
|
||
// single date.
|
||
background: `color-mix(in srgb, ${game.hue} ${
|
||
notStarted ? 11 : 22
|
||
}%, var(--color-surface))`,
|
||
// No start edge to draw when the bar begins before
|
||
// the view does. Dashed when the event has not
|
||
// started: the edge is a date in the future, not a
|
||
// thing that has happened.
|
||
borderLeft: clippedStart
|
||
? undefined
|
||
: `3px ${notStarted ? "dashed" : "solid"} ${game.hue}`,
|
||
// Frayed right = end unannounced; faded left =
|
||
// started before the window. Both are honest about
|
||
// what is not shown.
|
||
maskImage: edgeMask(clippedStart, unknownEnd),
|
||
color: "var(--color-ink)",
|
||
}}
|
||
>
|
||
{/* Sticky clamps to the bar's own box, so a name can
|
||
never wander outside the event it belongs to. */}
|
||
<span
|
||
className="sticky flex min-w-0 items-center gap-1.5 truncate"
|
||
style={{ left: PIN }}
|
||
>
|
||
{heading === null && width >= TAG_FROM && (
|
||
<span
|
||
className="eyebrow shrink-0 text-[0.5625rem]"
|
||
style={{ color: game.hue }}
|
||
>
|
||
{game.short}
|
||
</span>
|
||
)}
|
||
<span className="min-w-0 truncate">{event.title}</span>
|
||
{notStarted && (
|
||
/* The dashed edge and the labelled rule above it
|
||
say this to a reader looking at the board; a
|
||
screen reader gets neither, and a bar that has
|
||
not started is not a bar that is running. */
|
||
<span className="sr-only">
|
||
{" "}
|
||
— not started yet, begins{" "}
|
||
{dayLabel(clock.startsMs)}
|
||
</span>
|
||
)}
|
||
</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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Lanes, or one queue.
|
||
*
|
||
* Sits in the board's own header rather than down in settings, for the reason
|
||
* the list's sort control does: it is something a reader reaches for while
|
||
* looking at the board, not a preference they go and configure. It reads as a
|
||
* pair of pills rather than the view tabs' segmented control, because it
|
||
* reshapes the thing below it instead of replacing it.
|
||
*/
|
||
function StackControl({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: TimelineGroup;
|
||
onChange: (group: TimelineGroup) => void;
|
||
}) {
|
||
return (
|
||
<div role="group" aria-label="Stack the timeline" className="flex gap-1">
|
||
{TIMELINE_GROUPS.map((mode) => {
|
||
const on = value === mode.id;
|
||
return (
|
||
<button
|
||
key={mode.id}
|
||
type="button"
|
||
onClick={() => onChange(mode.id)}
|
||
aria-pressed={on}
|
||
title={mode.hint}
|
||
className={`rounded-full border px-2.5 py-1 text-[0.6875rem] font-medium transition-colors ${
|
||
on
|
||
? "border-ink/60 text-ink"
|
||
: "border-transparent text-faint hover:text-muted"
|
||
}`}
|
||
>
|
||
{mode.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Whether the board also plots what has not started yet.
|
||
*
|
||
* A switch rather than a third stacking pill, and drawn as one — it does not
|
||
* reshape the board, it changes what is on it. The count is the point: a board
|
||
* that quietly withheld nine events would be indistinguishable from a quiet
|
||
* fortnight, and "nothing scheduled" is exactly the wrong thing for this app to
|
||
* imply by accident.
|
||
*
|
||
* Absent when there is nothing waiting and it is switched off: a toggle that
|
||
* cannot change anything invites a tap that does nothing.
|
||
*/
|
||
function UpcomingControl({
|
||
on,
|
||
waiting,
|
||
onChange,
|
||
}: {
|
||
on: boolean;
|
||
waiting: number;
|
||
onChange: (on: boolean) => void;
|
||
}) {
|
||
if (waiting === 0 && !on) return null;
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(!on)}
|
||
aria-pressed={on}
|
||
title={
|
||
on
|
||
? "Stop plotting events that have not started yet"
|
||
: "Also plot events that have not started yet"
|
||
}
|
||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[0.6875rem] font-medium transition-colors ${
|
||
on
|
||
? "border-ink/60 text-ink"
|
||
: "border-hairline text-faint hover:text-muted"
|
||
}`}
|
||
>
|
||
Not started
|
||
<span className="tnum text-[0.625rem] text-faint">{waiting}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* One step of the scale control.
|
||
*
|
||
* Labelled by what it does to the board rather than "zoom in" and "zoom out",
|
||
* which say what happens to the picture and leave the reader to work out what
|
||
* that means for the dates.
|
||
*/
|
||
function ScaleButton({
|
||
label,
|
||
disabled,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
disabled: boolean;
|
||
onClick: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={label}
|
||
title={label}
|
||
className="grid size-6 place-items-center rounded-md border border-hairline text-xs font-semibold leading-none text-muted transition-colors duration-150 hover:border-faint hover:text-ink disabled:cursor-not-allowed disabled:border-hairline/60 disabled:text-faint/50 disabled:hover:text-faint/50"
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/** `18 Aug`, in the reader's own locale, for the week ticks. */
|
||
function dayLabel(ms: number): string {
|
||
return new Date(ms).toLocaleDateString(undefined, {
|
||
day: "numeric",
|
||
month: "short",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Every Monday in range.
|
||
*
|
||
* Weeks are the unit these schedules are actually written in — a patch is six
|
||
* of them — and a tick every seven days is the densest grid that still leaves
|
||
* room for a date on it.
|
||
*/
|
||
function weekBoundaries(min: number, max: number): number[] {
|
||
const d = new Date(min);
|
||
d.setUTCHours(0, 0, 0, 0);
|
||
// 0 is Sunday; step forward to the next Monday.
|
||
d.setUTCDate(d.getUTCDate() + ((8 - d.getUTCDay()) % 7));
|
||
const out: number[] = [];
|
||
while (d.getTime() <= max) {
|
||
out.push(d.getTime());
|
||
d.setUTCDate(d.getUTCDate() + 7);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function monthBoundaries(min: number, max: number) {
|
||
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) {
|
||
out.push({ ms: d.getTime(), label: short(d.getTime()) });
|
||
d.setUTCMonth(d.getUTCMonth() + 1);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Where the not-yet-started events begin, and how many begin there.
|
||
*
|
||
* Gacha schedules are not a smooth stream of start dates: a game ships a patch
|
||
* and six things open at once, so the honest unit is the clump, not the event.
|
||
* Grouped by day and then merged again by *distance on screen*, because two
|
||
* clumps a week apart are the same mark at six pixels a day and two separate
|
||
* marks at a hundred and eight — and a merged one says the range it covers
|
||
* rather than the first date in it, which would be a date the board is not
|
||
* drawing a rule at.
|
||
*
|
||
* Pure and exported because it decides what a reader is told about the future,
|
||
* which is worth a test rather than a rendering.
|
||
*/
|
||
export function startMarkers<
|
||
T extends { clock: { upcoming: boolean; startsMs: number } },
|
||
>(
|
||
rows: readonly T[],
|
||
x: (ms: number) => number,
|
||
): Array<{ ms: number; through: number; count: number }> {
|
||
const byDay = new Map<number, { ms: number; through: number; count: number }>();
|
||
for (const row of rows) {
|
||
if (!row.clock.upcoming) continue;
|
||
const day = Math.floor(row.clock.startsMs / DAY);
|
||
const at = byDay.get(day);
|
||
if (at === undefined) {
|
||
byDay.set(day, {
|
||
ms: row.clock.startsMs,
|
||
through: row.clock.startsMs,
|
||
count: 1,
|
||
});
|
||
} else {
|
||
at.ms = Math.min(at.ms, row.clock.startsMs);
|
||
at.through = Math.max(at.through, row.clock.startsMs);
|
||
at.count += 1;
|
||
}
|
||
}
|
||
|
||
const out: Array<{ ms: number; through: number; count: number }> = [];
|
||
for (const day of [...byDay.values()].sort((a, b) => a.ms - b.ms)) {
|
||
const last = out[out.length - 1];
|
||
if (last !== undefined && x(day.ms) - x(last.ms) < MARKER_GAP) {
|
||
last.through = Math.max(last.through, day.through);
|
||
last.count += day.count;
|
||
continue;
|
||
}
|
||
out.push({ ...day });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* What a start marker says out loud.
|
||
*
|
||
* The whole reason the markers exist: a bar drawn to the right of the now rule
|
||
* is only implicitly in the future, and "implicitly" is not a standard this app
|
||
* holds itself to anywhere else a date is involved.
|
||
*/
|
||
export function markerLabel(mark: {
|
||
ms: number;
|
||
through: number;
|
||
count: number;
|
||
}): string {
|
||
const from = dayLabel(mark.ms);
|
||
const to = dayLabel(mark.through);
|
||
if (mark.count === 1) return `starts ${from}`;
|
||
return from === to
|
||
? `${mark.count} start ${from}`
|
||
: `${mark.count} start ${from}–${to}`;
|
||
}
|