feat: add the Event Clock interface
Opens on the single event closest to expiring, at a size nothing else competes with — the reader arrives with one question. Below it, live events ordered by what ends soonest, then a quiet timeline view with one lane per game. Countdowns use tabular figures so ticking never reflows the row. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ec8cc97c1a
commit
b1d18eaf59
@@ -0,0 +1,118 @@
|
||||
import type { GameId, Region } from "../../shared/schema.ts";
|
||||
import { gameMeta } from "../../shared/games.ts";
|
||||
import type { Prefs } from "../state/usePrefs.ts";
|
||||
|
||||
const REGIONS: Array<{ id: Region; label: string }> = [
|
||||
{ id: "america", label: "America" },
|
||||
{ id: "europe", label: "Europe" },
|
||||
{ id: "asia", label: "Asia" },
|
||||
];
|
||||
|
||||
export function Controls({
|
||||
games,
|
||||
prefs,
|
||||
onToggleGame,
|
||||
onUpdate,
|
||||
onExport,
|
||||
onImport,
|
||||
}: {
|
||||
games: GameId[];
|
||||
prefs: Prefs;
|
||||
onToggleGame: (g: GameId) => void;
|
||||
onUpdate: (p: Partial<Prefs>) => void;
|
||||
onExport: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-t border-hairline px-4 py-5">
|
||||
<p className="eyebrow">Games</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{games.map((id) => {
|
||||
const game = gameMeta(id);
|
||||
const on = !prefs.hiddenGames.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onToggleGame(id)}
|
||||
aria-pressed={on}
|
||||
className="rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: on ? game.hue : "var(--color-hairline)",
|
||||
color: on ? game.hue : "var(--color-faint)",
|
||||
background: on
|
||||
? `color-mix(in srgb, ${game.hue} 12%, transparent)`
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
{game.short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<p className="eyebrow">Server region</p>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
{REGIONS.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onUpdate({ region: r.id, regionConfirmed: true })}
|
||||
aria-pressed={prefs.region === r.id}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
prefs.region === r.id
|
||||
? "border-ink/70 text-ink"
|
||||
: "border-hairline text-faint hover:text-muted"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.showCompleted}
|
||||
onChange={(e) => onUpdate({ showCompleted: e.target.checked })}
|
||||
className="size-4 accent-[var(--color-near)]"
|
||||
/>
|
||||
Show events I've finished
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-hairline pt-4">
|
||||
<p className="eyebrow">Your progress</p>
|
||||
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
|
||||
Completed events are saved in this browser only — there is no account.
|
||||
Move them to another device with a file.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExport}
|
||||
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<label className="cursor-pointer rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink">
|
||||
Import
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onImport(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect } from "react";
|
||||
import { gameMeta } from "../../shared/games.ts";
|
||||
import { formatAbsolute, formatRemaining } from "../../shared/time.ts";
|
||||
import type { RowEvent } from "./EventRow.tsx";
|
||||
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
||||
|
||||
export function EventDetail({
|
||||
row,
|
||||
completed,
|
||||
onToggle,
|
||||
onClose,
|
||||
}: {
|
||||
row: RowEvent;
|
||||
completed: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { event, clock } = row;
|
||||
const game = gameMeta(event.game);
|
||||
const heat = URGENCY_COLOR[clock.urgency];
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close details"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-ground/80 backdrop-blur-sm"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={event.title}
|
||||
className="relative max-h-[88vh] w-full overflow-y-auto rounded-t-2xl border border-hairline bg-surface p-5 sm:max-w-lg sm:rounded-2xl"
|
||||
>
|
||||
<p className="eyebrow" style={{ color: game.hue }}>
|
||||
{game.name}
|
||||
</p>
|
||||
<h2 className="mt-1.5 font-display text-xl font-semibold leading-snug">
|
||||
{event.title}
|
||||
</h2>
|
||||
{event.summary !== null && (
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted">{event.summary}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<Meter
|
||||
progress={clock.progress}
|
||||
urgency={clock.urgency}
|
||||
label="Time remaining"
|
||||
animate={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<Field label="Starts">
|
||||
{formatAbsolute(event.startsAt, event.startPrecision === "exact")}
|
||||
</Field>
|
||||
<Field label="Ends">
|
||||
{event.endsAt === null ? (
|
||||
<span className="text-faint">Not announced</span>
|
||||
) : (
|
||||
formatAbsolute(event.endsAt, event.endPrecision === "exact")
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Remaining">
|
||||
<span className="tnum font-display" style={{ color: heat }}>
|
||||
{clock.msRemaining === null
|
||||
? "unknown"
|
||||
: formatRemaining(clock.msRemaining)}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="Type">{event.type}</Field>
|
||||
</dl>
|
||||
|
||||
{event.endPrecision === "day" && event.endsAt !== null && (
|
||||
<p className="mt-3 text-xs leading-relaxed text-faint">
|
||||
The source gave a date but no time of day, so this end is accurate to
|
||||
the day only. Check in-game before the last hours.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(event.id)}
|
||||
className={`flex-1 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
completed
|
||||
? "border-hairline text-muted hover:text-ink"
|
||||
: "border-transparent bg-ink text-ground hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{completed ? "Mark not done" : "Mark done"}
|
||||
</button>
|
||||
<a
|
||||
href={event.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="rounded-lg border border-hairline px-4 py-2.5 text-sm text-muted transition-colors hover:text-ink"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="eyebrow">{label}</dt>
|
||||
<dd className="mt-0.5">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { gameMeta } from "../../shared/games.ts";
|
||||
import type { GachaEvent } from "../../shared/schema.ts";
|
||||
import { formatRemaining, type EventClock } from "../../shared/time.ts";
|
||||
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
||||
|
||||
export interface RowEvent {
|
||||
event: GachaEvent;
|
||||
clock: EventClock;
|
||||
}
|
||||
|
||||
interface EventRowProps {
|
||||
row: RowEvent;
|
||||
completed: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
}
|
||||
|
||||
export function EventRow({ row, completed, onToggle, onOpen }: EventRowProps) {
|
||||
const { event, clock } = row;
|
||||
const game = gameMeta(event.game);
|
||||
const heat = URGENCY_COLOR[clock.urgency];
|
||||
|
||||
const countdown = clock.upcoming
|
||||
? `starts in ${formatRemaining(clock.startsMs - Date.now())}`
|
||||
: clock.msRemaining === null
|
||||
? "end date unknown"
|
||||
: formatRemaining(clock.msRemaining);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`group relative flex gap-3 border-b border-hairline/70 px-4 py-3.5 transition-opacity ${
|
||||
completed ? "opacity-40" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Game identity: a hue stripe, never an urgency colour. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-1 w-[3px] shrink-0 rounded-full"
|
||||
style={{ background: game.hue }}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(event.id)}
|
||||
className="min-w-0 text-left"
|
||||
>
|
||||
<span
|
||||
className="eyebrow block truncate"
|
||||
style={{ color: game.hue }}
|
||||
>
|
||||
{game.short}
|
||||
</span>
|
||||
<span
|
||||
className={`block truncate text-[0.9375rem] font-medium leading-snug ${
|
||||
completed ? "line-through decoration-faint" : ""
|
||||
}`}
|
||||
>
|
||||
{event.title}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="tnum shrink-0 font-display text-sm font-semibold tabular-nums"
|
||||
style={{ color: clock.msRemaining === null ? "var(--color-faint)" : heat }}
|
||||
>
|
||||
{countdown}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5">
|
||||
<Meter
|
||||
progress={clock.upcoming ? 1 : clock.progress}
|
||||
urgency={clock.urgency}
|
||||
label={`${event.title}: ${countdown}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(event.id)}
|
||||
aria-pressed={completed}
|
||||
aria-label={completed ? `Mark ${event.title} not done` : `Mark ${event.title} done`}
|
||||
className={`mt-0.5 grid size-7 shrink-0 place-items-center self-center rounded-md border transition-colors ${
|
||||
completed
|
||||
? "border-transparent bg-near/20 text-near"
|
||||
: "border-hairline text-faint hover:border-faint hover:text-muted"
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
|
||||
<path
|
||||
d="M2.5 8.5l3.5 3.5 7.5-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { gameMeta } from "../../shared/games.ts";
|
||||
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 single event closest to expiring, at a size nothing else competes with.
|
||||
*
|
||||
* Deliberately not a stat grid. One number, because the reader has exactly one
|
||||
* question on arrival.
|
||||
*/
|
||||
export function NextUp({ row, onOpen }: { row: RowEvent | null; onOpen: (id: string) => void }) {
|
||||
if (row === null) {
|
||||
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">
|
||||
No live events in the games you have switched on. Turn a game back on
|
||||
below, or check again after the next patch.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { event, clock } = row;
|
||||
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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(event.id)}
|
||||
className="mt-2 block max-w-full text-left"
|
||||
>
|
||||
<h1 className="font-display text-[1.75rem] font-semibold leading-[1.15] tracking-tight">
|
||||
{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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { gameMeta } from "../../shared/games.ts";
|
||||
import type { GameId } from "../../shared/schema.ts";
|
||||
import { DAY } from "../../shared/time.ts";
|
||||
import type { RowEvent } from "./EventRow.tsx";
|
||||
import { URGENCY_COLOR } from "./Meter.tsx";
|
||||
|
||||
const DAY_WIDTH = 13; // px per day — dense enough to see a patch cycle at once
|
||||
|
||||
/**
|
||||
* One lane per game, bars spanning start→end, today pinned as a rule.
|
||||
*
|
||||
* The quiet view. The ending-soon list carries the page's boldness, so this
|
||||
* stays flat and legible: no gradients, no rounded chrome, just position and
|
||||
* length doing the work.
|
||||
*/
|
||||
export function Timeline({
|
||||
rows,
|
||||
now,
|
||||
onOpen,
|
||||
completions,
|
||||
}: {
|
||||
rows: RowEvent[];
|
||||
now: number;
|
||||
onOpen: (id: string) => void;
|
||||
completions: Record<string, unknown>;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="px-4 py-10 text-sm text-muted">
|
||||
Nothing to plot. Switch a game back on to see its schedule.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const starts = rows.map((r) => r.clock.startsMs);
|
||||
const ends = rows.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
|
||||
const min = Math.min(...starts, now) - 2 * DAY;
|
||||
const max = Math.max(...ends, now) + 2 * DAY;
|
||||
const totalDays = Math.ceil((max - min) / DAY);
|
||||
const width = totalDays * DAY_WIDTH;
|
||||
const x = (ms: number) => ((ms - min) / DAY) * DAY_WIDTH;
|
||||
|
||||
const byGame = new Map<GameId, RowEvent[]>();
|
||||
for (const row of rows) {
|
||||
byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]);
|
||||
}
|
||||
|
||||
const monthTicks = monthBoundaries(min, max);
|
||||
|
||||
return (
|
||||
<div className="scroll-x">
|
||||
<div style={{ width, minWidth: "100%" }} className="relative px-4 pb-8 pt-3">
|
||||
{/* Month rule, so a bar's absolute position means something. */}
|
||||
<div className="relative mb-3 h-4 border-b border-hairline">
|
||||
{monthTicks.map((t) => (
|
||||
<span
|
||||
key={t.ms}
|
||||
className="eyebrow absolute -translate-x-px whitespace-nowrap border-l border-hairline pl-1"
|
||||
style={{ left: x(t.ms) }}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-8 top-8 z-10 w-px bg-critical/70"
|
||||
style={{ left: x(now) + 16 }}
|
||||
>
|
||||
<span className="eyebrow absolute -top-4 left-1 text-critical">now</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[...byGame.entries()].map(([gameId, events]) => {
|
||||
const game = gameMeta(gameId);
|
||||
return (
|
||||
<div key={gameId}>
|
||||
<p className="eyebrow mb-1.5" style={{ color: game.hue }}>
|
||||
{game.short}
|
||||
</p>
|
||||
<div className="relative h-auto space-y-1">
|
||||
{events.map(({ event, clock }) => {
|
||||
const left = x(clock.startsMs);
|
||||
const unknownEnd = clock.endsMs === null;
|
||||
const right = x(clock.endsMs ?? clock.startsMs + 14 * DAY);
|
||||
const done = completions[event.id] !== undefined;
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(event.id)}
|
||||
title={event.title}
|
||||
className={`relative flex h-6 items-center overflow-hidden rounded-[3px] px-1.5 text-left text-[0.6875rem] font-medium transition-opacity hover:opacity-100 ${
|
||||
done ? "opacity-35" : "opacity-90"
|
||||
}`}
|
||||
style={{
|
||||
marginLeft: left,
|
||||
width: Math.max(right - left, 22),
|
||||
background: `color-mix(in srgb, ${game.hue} 22%, var(--color-surface))`,
|
||||
borderLeft: `2px solid ${game.hue}`,
|
||||
// A frayed right edge says the end is unannounced —
|
||||
// visually distinct from an event ending far away.
|
||||
maskImage: unknownEnd
|
||||
? "linear-gradient(90deg, #000 60%, transparent 100%)"
|
||||
: undefined,
|
||||
color: "var(--color-ink)",
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{event.title}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="ml-auto size-1.5 shrink-0 rounded-full"
|
||||
style={{ background: URGENCY_COLOR[clock.urgency] }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function monthBoundaries(min: number, max: number) {
|
||||
const out: Array<{ ms: number; label: string }> = [];
|
||||
const d = new Date(min);
|
||||
d.setUTCDate(1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
while (d.getTime() <= max) {
|
||||
if (d.getTime() >= min) {
|
||||
out.push({
|
||||
ms: d.getTime(),
|
||||
label: d.toLocaleDateString(undefined, { month: "short" }),
|
||||
});
|
||||
}
|
||||
d.setUTCMonth(d.getUTCMonth() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user