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:
co-authored by
Claude Opus 5
parent
ad45b52c84
commit
f4b133274a
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user