Files
gacha-event-tracker/src/client/components/NextUp.tsx
T
Lucas WintherandClaude Opus 5 74a50f8dca fix(next to expire): the headline answers the cursor
It is the thing on the page a reader is most likely to tap and the only
clickable text in the panel that did nothing under the pointer — the
queued deadlines beneath it brighten, and every list row brightens, so
the headline read as a heading rather than as a way in.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 05:31:20 +02:00

167 lines
5.8 KiB
TypeScript

import { useGameMeta } from "../state/gameMeta.tsx";
import { formatRemaining } from "../../shared/time.ts";
import type { RowEvent } from "./EventRow.tsx";
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
/**
* The thesis of the page: this app is a clock, so the first thing you see is
* the event closest to expiring, at a size nothing else competes with.
*
* The two behind it are listed under it, small. A reader asked for the three
* next deadlines and he was right that one is too few — finishing the headline
* event used to leave the panel pointing at something with no context — but
* three equal panels is a stat grid, and the reader arrives with one question.
* So the shape is one answer and two follow-ups, not three answers.
*/
export function NextUp({
rows,
focused,
onOpen,
}: {
/**
* The soonest-expiring events the reader has neither finished nor ignored,
* closest first. A panel headed "next to expire" is a list of deadlines they
* still have to meet, so events they already ticked off do not belong in it
* however visible they have chosen to keep them elsewhere.
*/
rows: RowEvent[];
/** Name of the game being focused on, when the page is narrowed to one. */
focused: string | null;
onOpen: (id: string) => void;
}) {
const gameMeta = useGameMeta();
const [lead, ...rest] = rows;
if (lead === undefined) {
return (
<section className="border-b border-hairline px-4 py-8">
<p className="eyebrow">Nothing running</p>
<p className="mt-2 max-w-sm text-sm text-muted">
{focused === null
? "Nothing live and unfinished in the games you have switched on. Turn a game back on below, or check again after the next patch."
: `Nothing live and unfinished in ${focused}. Move to the next game, or show all of them.`}
</p>
</section>
);
}
const { event, clock } = lead;
const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency];
const known = clock.msRemaining !== null;
return (
<section className="relative overflow-hidden border-b border-hairline px-4 pb-6 pt-5">
{/* A wash of the urgency colour, so the panel itself changes temperature
as the deadline closes in. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -top-24 h-48 opacity-[0.16] blur-2xl"
style={{ background: `radial-gradient(60% 100% at 50% 100%, ${heat}, transparent)` }}
/>
<div className="relative">
<p className="eyebrow">Next to expire</p>
{/* The one thing on the page a reader is most likely to tap, and it was
the only clickable text here that did not answer the cursor — the
queued rows under it brighten, the row list brightens, and this sat
inert and read as a heading rather than a way in. */}
<button
type="button"
onClick={() => onOpen(event.id)}
className="group mt-2 block max-w-full text-left"
>
<h1 className="font-display text-[1.75rem] font-semibold leading-[1.15] tracking-tight transition-colors duration-150 group-hover:text-ink-strong">
{event.title}
</h1>
<p className="mt-1 text-sm" style={{ color: game.hue }}>
{game.name}
</p>
</button>
<div className="mt-5 flex items-end justify-between gap-4">
<p
className="tnum font-display text-[2.75rem] font-bold leading-none tracking-tight"
style={{ color: known ? heat : "var(--color-faint)" }}
>
{known ? formatRemaining(clock.msRemaining ?? 0) : "unknown"}
</p>
<p className="pb-1 text-right text-xs leading-tight text-muted">
{known ? "left" : "no end date"}
<br />
{known ? "to finish it" : "announced"}
</p>
</div>
<div className="mt-4">
<Meter
progress={clock.progress}
urgency={clock.urgency}
label={`${event.title} time remaining`}
/>
</div>
{rest.length > 0 && (
<div className="mt-5 border-t border-hairline pt-3">
<p className="eyebrow">Then</p>
<ul className="mt-1.5">
{rest.map((row) => (
<QueuedRow key={row.event.id} row={row} onOpen={onOpen} />
))}
</ul>
</div>
)}
</div>
</section>
);
}
/**
* A deadline waiting behind the headline.
*
* Deliberately a different object from an `EventRow`: no meter, no summary, no
* badges. Its whole job is "what is after this one, and how long have I got" —
* anything more turns the panel into a second copy of the list it sits above.
*/
function QueuedRow({
row,
onOpen,
}: {
row: RowEvent;
onOpen: (id: string) => void;
}) {
const gameMeta = useGameMeta();
const { event, clock } = row;
const game = gameMeta(event.game);
const known = clock.msRemaining !== null;
return (
<li>
<button
type="button"
onClick={() => onOpen(event.id)}
className="group flex w-full items-baseline gap-2.5 py-1.5 text-left"
>
<span
aria-hidden
className="size-1.5 shrink-0 translate-y-[-1px] rounded-full"
style={{ background: game.hue }}
/>
<span className="min-w-0 flex-1 truncate text-[0.8125rem] leading-snug text-muted transition-colors duration-150 group-hover:text-ink">
<span className="sr-only">{game.name}: </span>
{event.title}
</span>
<span
className="tnum shrink-0 font-display text-xs font-semibold"
style={{
color: known ? URGENCY_COLOR[clock.urgency] : "var(--color-faint)",
}}
>
{known ? formatRemaining(clock.msRemaining ?? 0) : "no end date"}
</span>
</button>
</li>
);
}