diff --git a/src/client/App.tsx b/src/client/App.tsx
index 84712d5..6284eda 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -7,6 +7,7 @@ import { NextUp } from "./components/NextUp.tsx";
import { Timeline } from "./components/Timeline.tsx";
import { Welcome } from "./components/Welcome.tsx";
import { Colophon } from "./components/Colophon.tsx";
+import { Legend } from "./components/Legend.tsx";
import { useCompletions } from "./state/useCompletions.ts";
import { usePrefs } from "./state/usePrefs.ts";
import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts";
@@ -189,6 +190,7 @@ export function App() {
{live.length > 0 && (
1
@@ -284,10 +286,12 @@ function Shell({ children }: { children: React.ReactNode }) {
function Section({
title,
hint,
+ legend,
children,
}: {
title: string;
hint?: string | undefined;
+ legend?: boolean | undefined;
children: React.ReactNode;
}) {
return (
@@ -296,6 +300,7 @@ function Section({
{title}
{hint !== undefined && {hint}
}
+ {legend === true && }
);
diff --git a/src/client/components/EventRow.tsx b/src/client/components/EventRow.tsx
index d5115f8..ec62969 100644
--- a/src/client/components/EventRow.tsx
+++ b/src/client/components/EventRow.tsx
@@ -1,6 +1,10 @@
import { gameMeta } from "../../shared/games.ts";
import type { GachaEvent } from "../../shared/schema.ts";
-import { formatRemaining, type EventClock } from "../../shared/time.ts";
+import {
+ formatRemaining,
+ windowCaption,
+ type EventClock,
+} from "../../shared/time.ts";
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export interface RowEvent {
@@ -20,6 +24,8 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
+ const caption = windowCaption(clock, Date.now());
+
const countdown = clock.upcoming
? `starts in ${formatRemaining(clock.startsMs - Date.now())}`
: clock.msRemaining === null
@@ -84,8 +90,13 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
+ {/* Says what the bar is a proportion of. Without this the ticks
+ are a shape the reader has to guess the meaning of. */}
+
+ {caption}
+
diff --git a/src/client/components/Legend.tsx b/src/client/components/Legend.tsx
new file mode 100644
index 0000000..9d617eb
--- /dev/null
+++ b/src/client/components/Legend.tsx
@@ -0,0 +1,55 @@
+import type { Urgency } from "../../shared/time.ts";
+import { URGENCY_COLOR } from "./Meter.tsx";
+
+const STEPS: Array<{ urgency: Urgency; label: string }> = [
+ { urgency: "calm", label: "a week+" },
+ { urgency: "near", label: "under a week" },
+ { urgency: "soon", label: "under 3 days" },
+ { urgency: "critical", label: "under a day" },
+];
+
+/**
+ * What the bars and colours mean, said once.
+ *
+ * The meter encodes two things at once, and neither is guessable from looking
+ * at it. Every row now carries a plain caption ("9 of 12 days left"), which
+ * explains the bar; this explains the colour, which no per-row text can.
+ *
+ * Shown once above the list rather than repeated per row — a legend that
+ * appears 50 times is noise, not help.
+ */
+export function Legend() {
+ return (
+
+
+
+ {Array.from({ length: 8 }, (_, i) => (
+
+ ))}
+
+ bars = time left
+
+
+
+ colour = ends in
+ {STEPS.map((step) => (
+
+
+ {step.label}
+
+ ))}
+
+
+ );
+}
diff --git a/src/shared/time.ts b/src/shared/time.ts
index 06bb5eb..8cc338d 100644
--- a/src/shared/time.ts
+++ b/src/shared/time.ts
@@ -140,3 +140,39 @@ export function endingSoonestFirst(
if (b.clock.msRemaining === null) return -1;
return a.clock.msRemaining - b.clock.msRemaining;
}
+
+/**
+ * A plain-language caption for an event's window.
+ *
+ * The meter shows a proportion; this says what the proportion is *of*. Without
+ * it a reader has to infer that ticks mean remaining time, which is exactly the
+ * kind of "obvious to the author" encoding that leaves everyone else guessing.
+ */
+export function windowCaption(clock: EventClock, now: number): string {
+ const on = (ms: number) =>
+ new Date(ms).toLocaleDateString(undefined, { day: "numeric", month: "short" });
+
+ if (clock.upcoming) {
+ return clock.endsMs === null
+ ? `starts ${on(clock.startsMs)}`
+ : `${on(clock.startsMs)} – ${on(clock.endsMs)} · not started yet`;
+ }
+
+ if (clock.endsMs === null) {
+ return `started ${on(clock.startsMs)} · no end date announced`;
+ }
+
+ const totalDays = Math.max(
+ 1,
+ Math.round((clock.endsMs - clock.startsMs) / DAY),
+ );
+ const leftMs = Math.max(0, clock.endsMs - now);
+ const leftDays = Math.ceil(leftMs / DAY);
+
+ const left =
+ leftMs < DAY
+ ? `${Math.max(1, Math.floor(leftMs / HOUR))} of ${totalDays * 24} hours left`
+ : `${leftDays} of ${totalDays} days left`;
+
+ return `${on(clock.startsMs)} – ${on(clock.endsMs)} · ${left}`;
+}