From b1d18eaf5982c9d28d00f539556d8fdc1f2745f6 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 15 Aug 2026 00:28:45 +0200 Subject: [PATCH] feat: add the Event Clock interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/App.tsx | 301 ++++++++++++++++++++++++++ src/client/components/Controls.tsx | 118 ++++++++++ src/client/components/EventDetail.tsx | 124 +++++++++++ src/client/components/EventRow.tsx | 105 +++++++++ src/client/components/NextUp.tsx | 81 +++++++ src/client/components/Timeline.tsx | 144 ++++++++++++ src/client/main.tsx | 12 + 7 files changed, 885 insertions(+) create mode 100644 src/client/App.tsx create mode 100644 src/client/components/Controls.tsx create mode 100644 src/client/components/EventDetail.tsx create mode 100644 src/client/components/EventRow.tsx create mode 100644 src/client/components/NextUp.tsx create mode 100644 src/client/components/Timeline.tsx create mode 100644 src/client/main.tsx diff --git a/src/client/App.tsx b/src/client/App.tsx new file mode 100644 index 0000000..9983d96 --- /dev/null +++ b/src/client/App.tsx @@ -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({ status: "loading" }); + const [view, setView] = useState("soon"); + const [openId, setOpenId] = useState(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(() => { + 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( + () => [...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

Loading events…

; + } + + if (state.status === "error") { + return ( + +
+

Events unavailable

+

+ {state.message} +

+
+
+ ); + } + + const staleSources = state.feed.sources.filter( + (s) => s.lastSuccessAt === null || now - Date.parse(s.lastSuccessAt) > 2 * DAY, + ); + + return ( + +
+
+

+ EVENTCLOCK +

+

+ {live.length} live · {upcoming.length} upcoming +

+
+ +
+ {( + [ + ["soon", "Ending soon"], + ["calendar", "Calendar"], + ] as const + ).map(([id, label]) => ( + + ))} +
+
+ + {view === "soon" ? ( + <> + + + {live.length > 0 && ( +
1 + ? `next after this ends in ${formatRemaining( + live[1]?.clock.msRemaining ?? 0, + )}` + : undefined + } + > + {live.map((row) => ( + + ))} +
+ )} + + {upcoming.length > 0 && ( +
+ {upcoming.map((row) => ( + + ))} +
+ )} + + {visible.length === 0 && ( +

+ Nothing to show. Every game is switched off, or you've finished + everything and hidden completed events. +

+ )} + + ) : ( + + )} + + exportProgress(completions, prefs)} + onImport={(file) => void importProgress(file, merge)} + /> + +
+

+ Dates come from community wikis and are shown in your local time. Every + event links to its source — check there before the last hours. +

+ {staleSources.length > 0 && ( +

+ {staleSources.length} source + {staleSources.length > 1 ? "s have" : " has"} not refreshed in over two + days. Some end dates may have moved. +

+ )} +
+ + {openRow !== null && ( + setOpenId(null)} + /> + )} +
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Section({ + title, + hint, + children, +}: { + title: string; + hint?: string | undefined; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {hint !== undefined &&

{hint}

} +
+
    {children}
+
+ ); +} + +function exportProgress( + completions: Record, + 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) => 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); + } + } catch { + alert("That file couldn't be read. Export a fresh copy and try again."); + } +} diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx new file mode 100644 index 0000000..dc80c0f --- /dev/null +++ b/src/client/components/Controls.tsx @@ -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) => void; + onExport: () => void; + onImport: (file: File) => void; +}) { + return ( +
+

Games

+
+ {games.map((id) => { + const game = gameMeta(id); + const on = !prefs.hiddenGames.includes(id); + return ( + + ); + })} +
+ +
+
+

Server region

+
+ {REGIONS.map((r) => ( + + ))} +
+
+ + +
+ +
+

Your progress

+

+ Completed events are saved in this browser only — there is no account. + Move them to another device with a file. +

+
+ + +
+
+
+ ); +} diff --git a/src/client/components/EventDetail.tsx b/src/client/components/EventDetail.tsx new file mode 100644 index 0000000..061644d --- /dev/null +++ b/src/client/components/EventDetail.tsx @@ -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 ( +
+ + + Source + +
+ + + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/src/client/components/EventRow.tsx b/src/client/components/EventRow.tsx new file mode 100644 index 0000000..0d98186 --- /dev/null +++ b/src/client/components/EventRow.tsx @@ -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 ( +
  • + {/* Game identity: a hue stripe, never an urgency colour. */} + + +
    +
    + + + + {countdown} + +
    + +
    + +
    +
    + + +
  • + ); +} diff --git a/src/client/components/NextUp.tsx b/src/client/components/NextUp.tsx new file mode 100644 index 0000000..f73a574 --- /dev/null +++ b/src/client/components/NextUp.tsx @@ -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 ( +
    +

    Nothing running

    +

    + No live events in the games you have switched on. Turn a game back on + below, or check again after the next patch. +

    +
    + ); + } + + const { event, clock } = row; + const game = gameMeta(event.game); + const heat = URGENCY_COLOR[clock.urgency]; + const known = clock.msRemaining !== null; + + return ( +
    + {/* A wash of the urgency colour, so the panel itself changes temperature + as the deadline closes in. */} +
    + +
    +

    Next to expire

    + + + +
    +

    + {known ? formatRemaining(clock.msRemaining ?? 0) : "unknown"} +

    +

    + {known ? "left" : "no end date"} +
    + {known ? "to finish it" : "announced"} +

    +
    + +
    + +
    +
    +
    + ); +} diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx new file mode 100644 index 0000000..a5a8abb --- /dev/null +++ b/src/client/components/Timeline.tsx @@ -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; +}) { + if (rows.length === 0) { + return ( +

    + Nothing to plot. Switch a game back on to see its schedule. +

    + ); + } + + 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(); + for (const row of rows) { + byGame.set(row.event.game, [...(byGame.get(row.event.game) ?? []), row]); + } + + const monthTicks = monthBoundaries(min, max); + + return ( +
    +
    + {/* Month rule, so a bar's absolute position means something. */} +
    + {monthTicks.map((t) => ( + + {t.label} + + ))} +
    + +
    + now +
    + +
    + {[...byGame.entries()].map(([gameId, events]) => { + const game = gameMeta(gameId); + return ( +
    +

    + {game.short} +

    +
    + {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 ( + + ); + })} +
    +
    + ); + })} +
    +
    +
    + ); +} + +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; +} diff --git a/src/client/main.tsx b/src/client/main.tsx new file mode 100644 index 0000000..bf619ac --- /dev/null +++ b/src/client/main.tsx @@ -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( + + + , +);