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 { 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 && (
|
||||
<Section
|
||||
legend
|
||||
title="Running now"
|
||||
hint={
|
||||
live.length > 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({
|
||||
<h2 className="eyebrow">{title}</h2>
|
||||
{hint !== undefined && <p className="text-xs text-faint">{hint}</p>}
|
||||
</div>
|
||||
{legend === true && <Legend />}
|
||||
<ul className="border-t border-hairline">{children}</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
<Meter
|
||||
progress={clock.upcoming ? 1 : clock.progress}
|
||||
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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
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