From f4b133274acfd3d3c4e8644ed92693260b74d8f9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 01:09:54 +0200 Subject: [PATCH] fix: say what the bars and colours mean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meter encoded two things at once and neither was guessable from looking at it — obvious to whoever built it, opaque to everyone else. Every row now carries a plain caption under the bar ("Aug 10 – Aug 17 · 3 of 7 days left", "started Apr 22 · no end date announced"), which explains the bar and doubles as the exact dates people wanted anyway. A legend above the list explains the colour ramp, which no per-row text can — shown once, because a legend repeated fifty times is noise. Co-Authored-By: Claude Opus 5 (1M context) --- src/client/App.tsx | 5 +++ src/client/components/EventRow.tsx | 15 ++++++-- src/client/components/Legend.tsx | 55 ++++++++++++++++++++++++++++++ src/shared/time.ts | 36 +++++++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/client/components/Legend.tsx 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 && }
    {children}
); 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}`; +}