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