fix: say what the bars and colours mean

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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 01:09:54 +02:00
co-authored by Claude Opus 5
parent ad45b52c84
commit f4b133274a
4 changed files with 109 additions and 2 deletions
+5
View File
@@ -7,6 +7,7 @@ import { NextUp } from "./components/NextUp.tsx";
import { Timeline } from "./components/Timeline.tsx"; import { Timeline } from "./components/Timeline.tsx";
import { Welcome } from "./components/Welcome.tsx"; import { Welcome } from "./components/Welcome.tsx";
import { Colophon } from "./components/Colophon.tsx"; import { Colophon } from "./components/Colophon.tsx";
import { Legend } from "./components/Legend.tsx";
import { useCompletions } from "./state/useCompletions.ts"; import { useCompletions } from "./state/useCompletions.ts";
import { usePrefs } from "./state/usePrefs.ts"; import { usePrefs } from "./state/usePrefs.ts";
import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts"; import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts";
@@ -189,6 +190,7 @@ export function App() {
{live.length > 0 && ( {live.length > 0 && (
<Section <Section
legend
title="Running now" title="Running now"
hint={ hint={
live.length > 1 live.length > 1
@@ -284,10 +286,12 @@ function Shell({ children }: { children: React.ReactNode }) {
function Section({ function Section({
title, title,
hint, hint,
legend,
children, children,
}: { }: {
title: string; title: string;
hint?: string | undefined; hint?: string | undefined;
legend?: boolean | undefined;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
@@ -296,6 +300,7 @@ function Section({
<h2 className="eyebrow">{title}</h2> <h2 className="eyebrow">{title}</h2>
{hint !== undefined && <p className="text-xs text-faint">{hint}</p>} {hint !== undefined && <p className="text-xs text-faint">{hint}</p>}
</div> </div>
{legend === true && <Legend />}
<ul className="border-t border-hairline">{children}</ul> <ul className="border-t border-hairline">{children}</ul>
</section> </section>
); );
+13 -2
View File
@@ -1,6 +1,10 @@
import { gameMeta } from "../../shared/games.ts"; import { gameMeta } from "../../shared/games.ts";
import type { GachaEvent } from "../../shared/schema.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"; import { Meter, URGENCY_COLOR } from "./Meter.tsx";
export interface RowEvent { export interface RowEvent {
@@ -20,6 +24,8 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
const game = gameMeta(event.game); const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency]; const heat = URGENCY_COLOR[clock.urgency];
const caption = windowCaption(clock, Date.now());
const countdown = clock.upcoming const countdown = clock.upcoming
? `starts in ${formatRemaining(clock.startsMs - Date.now())}` ? `starts in ${formatRemaining(clock.startsMs - Date.now())}`
: clock.msRemaining === null : clock.msRemaining === null
@@ -84,8 +90,13 @@ export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
<Meter <Meter
progress={clock.upcoming ? 1 : clock.progress} progress={clock.upcoming ? 1 : clock.progress}
urgency={clock.urgency} urgency={clock.urgency}
label={`${event.title}: ${countdown}`} label={`${event.title}: ${caption}`}
/> />
{/* Says what the bar is a proportion of. Without this the ticks
are a shape the reader has to guess the meaning of. */}
<p className="mt-1.5 text-[0.6875rem] leading-none text-faint">
{caption}
</p>
</div> </div>
</div> </div>
+55
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 pb-3">
<p className="flex items-center gap-2 text-[0.6875rem] leading-none text-faint">
<span aria-hidden className="flex gap-[2px]">
{Array.from({ length: 8 }, (_, i) => (
<span
key={i}
className="h-2.5 w-[3px] rounded-[1px]"
style={{
background:
i < 5 ? URGENCY_COLOR.near : "var(--color-hairline)",
}}
/>
))}
</span>
bars = time left
</p>
<p className="flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[0.6875rem] leading-none text-faint">
<span>colour = ends in</span>
{STEPS.map((step) => (
<span key={step.urgency} className="flex items-center gap-1">
<span
aria-hidden
className="size-2 rounded-[1px]"
style={{ background: URGENCY_COLOR[step.urgency] }}
/>
{step.label}
</span>
))}
</p>
</div>
);
}
+36
View File
@@ -140,3 +140,39 @@ export function endingSoonestFirst(
if (b.clock.msRemaining === null) return -1; if (b.clock.msRemaining === null) return -1;
return a.clock.msRemaining - b.clock.msRemaining; 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}`;
}