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,301 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { fetchFeed, type FeedState } from "./api.ts";
|
||||
import { Controls } from "./components/Controls.tsx";
|
||||
import { EventDetail } from "./components/EventDetail.tsx";
|
||||
import { EventRow, type RowEvent } from "./components/EventRow.tsx";
|
||||
import { NextUp } from "./components/NextUp.tsx";
|
||||
import { Timeline } from "./components/Timeline.tsx";
|
||||
import { useCompletions } from "./state/useCompletions.ts";
|
||||
import { usePrefs } from "./state/usePrefs.ts";
|
||||
import { clockFor, DAY, endingSoonestFirst, formatRemaining } from "../shared/time.ts";
|
||||
import type { GameId } from "../shared/schema.ts";
|
||||
|
||||
type View = "soon" | "calendar";
|
||||
|
||||
/** Ticks once a second so countdowns stay honest without re-fetching. */
|
||||
function useNow(intervalMs = 1000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [state, setState] = useState<FeedState>({ status: "loading" });
|
||||
const [view, setView] = useState<View>("soon");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const now = useNow();
|
||||
const { prefs, update, toggleGame } = usePrefs();
|
||||
const { completions, toggle, merge } = useCompletions();
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController();
|
||||
fetchFeed(ac.signal)
|
||||
.then((feed) => setState({ status: "ready", feed }))
|
||||
.catch((err: unknown) => {
|
||||
if (ac.signal.aborted) return;
|
||||
setState({
|
||||
status: "error",
|
||||
message: err instanceof Error ? err.message : "Could not load events.",
|
||||
});
|
||||
});
|
||||
return () => ac.abort();
|
||||
}, []);
|
||||
|
||||
const allRows = useMemo<RowEvent[]>(() => {
|
||||
if (state.status !== "ready") return [];
|
||||
return state.feed.events
|
||||
.filter((e) => e.status === "published")
|
||||
.map((event) => ({ event, clock: clockFor(event, prefs.region, now) }));
|
||||
// `now` intentionally excluded: recomputing every clock each second is
|
||||
// wasteful, and the countdown text re-renders from `now` anyway.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, prefs.region, Math.floor(now / 60_000)]);
|
||||
|
||||
const games = useMemo<GameId[]>(
|
||||
() => [...new Set(allRows.map((r) => r.event.game))],
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
allRows
|
||||
.filter((r) => !prefs.hiddenGames.includes(r.event.game))
|
||||
.filter((r) => !r.clock.ended)
|
||||
.filter((r) => prefs.showCompleted || completions[r.event.id] === undefined)
|
||||
.sort(endingSoonestFirst),
|
||||
[allRows, prefs.hiddenGames, prefs.showCompleted, completions],
|
||||
);
|
||||
|
||||
const live = visible.filter((r) => r.clock.live);
|
||||
const upcoming = visible.filter((r) => r.clock.upcoming);
|
||||
const next = live.find((r) => r.clock.msRemaining !== null) ?? live[0] ?? null;
|
||||
const openRow = allRows.find((r) => r.event.id === openId) ?? null;
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <Shell><p className="px-4 py-16 text-sm text-muted">Loading events…</p></Shell>;
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<Shell>
|
||||
<div className="px-4 py-16">
|
||||
<p className="eyebrow text-critical">Events unavailable</p>
|
||||
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted">
|
||||
{state.message}
|
||||
</p>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const staleSources = state.feed.sources.filter(
|
||||
(s) => s.lastSuccessAt === null || now - Date.parse(s.lastSuccessAt) > 2 * DAY,
|
||||
);
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<header className="flex items-center justify-between px-4 pb-3 pt-5">
|
||||
<div>
|
||||
<p className="font-display text-[0.9375rem] font-bold tracking-[0.02em]">
|
||||
EVENT<span className="text-near">CLOCK</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-faint">
|
||||
{live.length} live · {upcoming.length} upcoming
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="View"
|
||||
className="flex rounded-lg border border-hairline p-0.5"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["soon", "Ending soon"],
|
||||
["calendar", "Calendar"],
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
role="tab"
|
||||
aria-selected={view === id}
|
||||
onClick={() => setView(id)}
|
||||
className={`rounded-[6px] px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
view === id ? "bg-raised text-ink" : "text-faint hover:text-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{view === "soon" ? (
|
||||
<>
|
||||
<NextUp row={next} onOpen={setOpenId} />
|
||||
|
||||
{live.length > 0 && (
|
||||
<Section
|
||||
title="Running now"
|
||||
hint={
|
||||
live.length > 1
|
||||
? `next after this ends in ${formatRemaining(
|
||||
live[1]?.clock.msRemaining ?? 0,
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{live.map((row) => (
|
||||
<EventRow
|
||||
key={row.event.id}
|
||||
row={row}
|
||||
completed={completions[row.event.id] !== undefined}
|
||||
onToggle={toggle}
|
||||
onOpen={setOpenId}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{upcoming.length > 0 && (
|
||||
<Section title="Not started yet">
|
||||
{upcoming.map((row) => (
|
||||
<EventRow
|
||||
key={row.event.id}
|
||||
row={row}
|
||||
completed={completions[row.event.id] !== undefined}
|
||||
onToggle={toggle}
|
||||
onOpen={setOpenId}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{visible.length === 0 && (
|
||||
<p className="px-4 py-12 text-sm leading-relaxed text-muted">
|
||||
Nothing to show. Every game is switched off, or you've finished
|
||||
everything and hidden completed events.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Timeline
|
||||
rows={visible}
|
||||
now={now}
|
||||
onOpen={setOpenId}
|
||||
completions={completions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
games={games}
|
||||
prefs={prefs}
|
||||
onToggleGame={toggleGame}
|
||||
onUpdate={update}
|
||||
onExport={() => exportProgress(completions, prefs)}
|
||||
onImport={(file) => void importProgress(file, merge)}
|
||||
/>
|
||||
|
||||
<footer className="px-4 pb-10 pt-2 text-xs leading-relaxed text-faint">
|
||||
<p>
|
||||
Dates come from community wikis and are shown in your local time. Every
|
||||
event links to its source — check there before the last hours.
|
||||
</p>
|
||||
{staleSources.length > 0 && (
|
||||
<p className="mt-2 text-soon">
|
||||
{staleSources.length} source
|
||||
{staleSources.length > 1 ? "s have" : " has"} not refreshed in over two
|
||||
days. Some end dates may have moved.
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{openRow !== null && (
|
||||
<EventDetail
|
||||
row={openRow}
|
||||
completed={completions[openRow.event.id] !== undefined}
|
||||
onToggle={toggle}
|
||||
onClose={() => setOpenId(null)}
|
||||
/>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto min-h-full max-w-2xl border-hairline sm:border-x">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string | undefined;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="pt-5">
|
||||
<div className="flex items-baseline justify-between gap-3 px-4 pb-2">
|
||||
<h2 className="eyebrow">{title}</h2>
|
||||
{hint !== undefined && <p className="text-xs text-faint">{hint}</p>}
|
||||
</div>
|
||||
<ul className="border-t border-hairline">{children}</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function exportProgress(
|
||||
completions: Record<string, { completedAt: string }>,
|
||||
prefs: unknown,
|
||||
) {
|
||||
const blob = new Blob(
|
||||
[
|
||||
JSON.stringify(
|
||||
{
|
||||
format: "gacha-tracker-export",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
completions,
|
||||
prefs,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
],
|
||||
{ type: "application/json" },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `event-clock-progress-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function importProgress(
|
||||
file: File,
|
||||
merge: (c: Record<string, { completedAt: string }>) => void,
|
||||
) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await file.text());
|
||||
const data = parsed as { format?: string; completions?: unknown };
|
||||
if (data.format !== "gacha-tracker-export") {
|
||||
alert("That file isn't an Event Clock export.");
|
||||
return;
|
||||
}
|
||||
if (typeof data.completions === "object" && data.completions !== null) {
|
||||
merge(data.completions as Record<string, { completedAt: string }>);
|
||||
}
|
||||
} catch {
|
||||
alert("That file couldn't be read. Export a fresh copy and try again.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) throw new Error("#root is missing from index.html");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user