further changes to backend for adjusting points, showing timestamps, and how categories are listed. uploaded build of react and actual frontend source

This commit is contained in:
jenz
2026-09-30 16:02:33 +02:00
parent 54827b01ba
commit 6019e6854d
40 changed files with 5256 additions and 21 deletions
@@ -0,0 +1,2 @@
# Base URL of the racetimer backend API (no trailing slash).
VITE_API_BASE=https://racebackend.unloze.com/racetimer_endpoints-1.0/api
@@ -0,0 +1,3 @@
node_modules/
dist/
.env.local
+60
View File
@@ -0,0 +1,60 @@
# Racetimer website (2026)
React 18 + TypeScript + Vite frontend for the UNLOZE racetimer. It reads everything from the new Java backend (`racetimer_endpoints`).
## Pages
| URL | What it shows |
|---|---|
| `#/leaderboard` | Players ranked by points, with a ZE1 / ZE2 switch. The choice is remembered in the browser. |
| `#/player/<steamid>` | Profile with ZE1 and ZE2 points and ranks. **Records** tab: best time per category with place, bonus multiplier, points and date ("Legacy record" for Classic data). A faster run that was invalidated is listed too, marked **Run invalidated**. **Improvement history** tab: every improvement and how much faster it was, with a chart for one category. |
| `#/maps` | Every map with its stages, categories, servers and record counts. |
| `#/map/<mapname>?stage=&category=` | Stage tabs and category cards. Categories with exactly the same server settings share a number on ZE1 and ZE2 ("Category 1 ZE1", "Category 1 ZE2"). Shows the category's server settings, highlighting values that differ from other categories of the stage. Leaderboard with gap to #1, bonus, points and the 50% points cut-off line. Invalidated runs stay in the list where their time places them, marked **Invalid**, with no position and 0 points. |
| `#/search?q=` | Players and maps matching the header search box. |
| `#/points` | Plain-language explanation of the points rules. |
Old links still work: `#/mapboard` goes to `#/maps`, and `#/map/<name>/<stage>` opens that stage.
Dates are shown in full, down to the second, in the visitor's own time zone.
## Admins
The footer has **Admin sign-in with Steam**. It goes to the backend (`/api/auth/steam/login`). The backend checks SourceBans and sends the admin back with `#token=…`. The site stores the token in the browser and removes it from the address bar.
While signed in:
- a yellow bar shows who is signed in, with **Sign out**
- map leaderboards, profile records and profile history show **Invalidate** / **Restore** buttons for single runs
- the category panel has **Invalidate** / **Restore** for the whole category
Every action asks for confirmation first. After a change, all pages reload their data.
Everybody (signed in or not) sees which admin invalidated a run or category and when, under the Invalid tag.
Set `frontendUrl` in the backend settings to this site's URL, or the Steam sign-in cannot find its way back.
## Build and deploy
Requires Node 18 or newer.
```bash
npm install
npm run build # output in dist/
```
Upload the contents of `dist/` to the web server. The site uses `#` URLs, so no server rewrite rules are needed, and it works from any folder.
The backend URL defaults to `https://racebackend.unloze.com/racetimer_endpoints-1.0/api`. To use another one, create `.env.local` from `.env.example` before building:
```
VITE_API_BASE=https://your-backend/racetimer_endpoints-1.0/api
```
For development, `npm run dev` serves the site on http://localhost:5173 with live reload.
## Code layout
- `src/api/types.ts`: JSON shapes returned by the backend. Keep in sync with `racetimer/dto/Dto.java`.
- `src/api/client.ts`: all API calls and the login token.
- `src/pages/`: one file per page.
- `src/components/`: shared pieces (layout, chips, admin button, chart).
- `src/styles.css`: the dark theme. Colors are CSS variables at the top of the file.
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0d1117" />
<meta name="description" content="UNLOZE Zombie Escape racetimer: leaderboards, map records and player profiles." />
<link rel="icon" href="./favicon.ico" />
<title>UNLOZE Racetimer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
{
"name": "racetimer_react",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,36 @@
import { useState } from "react";
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Layout } from "./components/Layout";
import { LeaderboardPage } from "./pages/LeaderboardPage";
import { PlayerPage } from "./pages/PlayerPage";
import { MapsPage } from "./pages/MapsPage";
import { MapPage } from "./pages/MapPage";
import { SearchPage } from "./pages/SearchPage";
import { PointsPage } from "./pages/PointsPage";
import { NotFound } from "./components/States";
/** Old links looked like #/map/<name>/<stage>. */
function OldMapLink() {
const { mapname, stage } = useParams();
return <Navigate replace to={`/map/${encodeURIComponent(mapname ?? "")}?stage=${stage ?? 1}`} />;
}
export function App({ loginError }: { loginError: string | null }) {
const [notice, setNotice] = useState(loginError);
return (
<Layout notice={notice} onDismissNotice={() => setNotice(null)}>
<Routes>
<Route path="/" element={<Navigate replace to="/leaderboard" />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/player/:steamid" element={<PlayerPage />} />
<Route path="/maps" element={<MapsPage />} />
<Route path="/mapboard" element={<Navigate replace to="/maps" />} />
<Route path="/map/:mapname" element={<MapPage />} />
<Route path="/map/:mapname/:stage" element={<OldMapLink />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/points" element={<PointsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Layout>
);
}
@@ -0,0 +1,96 @@
import type {
AuthUser,
CategoryBoard,
HistoryEntry,
MapInfo,
Player,
PlayerMapRow,
ServerTag,
} from "./types";
export const API_BASE: string = (
import.meta.env.VITE_API_BASE ?? "https://racebackend.unloze.com/racetimer_endpoints-1.0/api"
).replace(/\/+$/, "");
const TOKEN_KEY = "racetimer.token";
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
export function setToken(token: string | null): void {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch {
/* storage blocked: the session only lasts for this page load */
}
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { Accept: "application/json" };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
if (init?.body) headers["Content-Type"] = "application/json";
let res: Response;
try {
res = await fetch(`${API_BASE}/${path}`, { ...init, headers: { ...headers, ...(init?.headers ?? {}) } });
} catch {
throw new ApiError(0, "Could not reach the racetimer server. Check your connection and try again.");
}
const text = await res.text();
if (!res.ok) {
let message = `Request failed (${res.status})`;
try {
const body = JSON.parse(text);
if (body?.errorMessage) message = body.errorMessage;
} catch {
/* not JSON */
}
throw new ApiError(res.status, message);
}
return (text ? JSON.parse(text) : null) as T;
}
const enc = encodeURIComponent;
export const api = {
leaderboard: (server: ServerTag, offset: number) =>
request<Player[]>(`timers/leaderboard/${offset}?server=${server}`),
player: (steamId: string, server: ServerTag) =>
request<Player>(`timers/player/${enc(steamId)}?server=${server}`),
playerMaps: (steamId: string, offset: number) =>
request<PlayerMapRow[]>(`timers/player/maps/${enc(steamId)}/${offset}`),
playerHistory: (steamId: string, offset: number, categoryId?: number) =>
request<HistoryEntry[]>(
`timers/player/history/${enc(steamId)}/${offset}` + (categoryId ? `?categoryId=${categoryId}` : ""),
),
allMaps: () => request<MapInfo[]>("timers/allmaps"),
map: (mapName: string) => request<MapInfo>(`timers/map/${enc(mapName)}`),
category: (id: number, offset: number) => request<CategoryBoard>(`timers/category/${id}/${offset}`),
searchPlayers: (q: string, server: ServerTag) =>
request<Player[]>(`timers/searchplayers/${enc(q)}?server=${server}`),
me: () => request<AuthUser>("auth/me"),
setRecordInvalid: (recordId: number, invalid: boolean) =>
request<{ id: number; invalid: boolean }>(`admin/records/${recordId}`, {
method: "PUT",
body: JSON.stringify({ invalid }),
}),
setCategoryInvalid: (categoryId: number, invalid: boolean) =>
request<{ id: number; invalid: boolean }>(`admin/categories/${categoryId}`, {
method: "PUT",
body: JSON.stringify({ invalid }),
}),
};
export const steamLoginUrl = `${API_BASE}/auth/steam/login`;
@@ -0,0 +1,182 @@
// JSON shapes returned by the racetimer backend (racetimer.dto.Dto).
// Field names must match the backend exactly.
export type ServerTag = "ze1" | "ze2";
export const SERVERS: ServerTag[] = ["ze1", "ze2"];
export interface ServerStats {
points: number;
rank: number;
times: number;
}
export interface Badge {
name: string;
url: string;
}
export interface Player {
steamID: string;
steamID64: string | null;
name: string;
Avatar: string;
/** Rank on `server`. */
Rank: number;
/** Points on `server`. */
PlayerPoints: number;
Times: number;
UrlBanners: string[];
server: ServerTag;
servers: Record<ServerTag, ServerStats>;
badges: Badge[];
}
export interface Cvar {
name: string;
value: string;
}
/** Who marked something invalid and when. Fields are null for rows flagged before this was recorded. */
export interface Invalidation {
steamID: string | null;
steamID64: string | null;
name: string | null;
/** Unix seconds. */
at: number | null;
}
export interface Category {
id: number;
/** "Category N", same numbering as in-game. */
categoryNumber: number;
mapName: string;
stage: number;
serverTag: ServerTag;
serverCvars: string;
cvars: Cvar[];
/** Migrated CLASSIC RACETIMER data: points divided by 10, dates unknown. */
isLegacy: boolean;
isInvalid: boolean;
invalidation?: Invalidation | null;
givesPoints: boolean;
completions: number;
fastestTime: number | null;
}
export interface Stage {
stage: number;
allCategoriesInvalid: boolean;
categories: Category[];
}
export interface MapInfo {
mapName: string;
allCategoriesInvalid: boolean;
stages: Stage[];
}
export interface BoardEntry {
recordId: number;
/** 0 for an invalidated run. */
position: number;
steamID: string;
steamID64: string | null;
name: string;
avatar: string;
badgesUrls: string[];
time: number;
points: number;
/** 1.0 = no bonus. */
bonusMultiplier: number;
/** Raised by the small-Classic-board rules (at least 1, top 10 places 1 apart). */
pointsRaised?: boolean;
/** Unix seconds; null for legacy records. */
recordedAt: number | null;
isLegacy: boolean;
/** Invalidated by an admin: listed in time order, but no position and no points. */
isInvalid: boolean;
invalidation?: Invalidation | null;
}
export interface InvalidatedEntry {
recordId: number;
steamID: string;
steamID64: string | null;
name: string;
avatar: string;
time: number;
recordedAt: number | null;
isLegacy: boolean;
isInvalid: true;
invalidation?: Invalidation | null;
}
export interface CategoryBoard {
category: Category;
offset: number;
pageSize: number;
/** Valid entries and invalidated runs together, fastest first. */
entries: BoardEntry[];
/** The invalidated runs only (they are also in entries). */
invalidated: InvalidatedEntry[];
}
export interface PlayerMapRow {
recordId: number;
categoryId: number;
mapName: string;
stage: number;
categoryNumber: number;
serverTag: ServerTag;
isLegacy: boolean;
categoryInvalid: boolean;
/** This run was invalidated by an admin: position 0, no points. */
isInvalid: boolean;
invalidation?: Invalidation | null;
categoryInvalidation?: Invalidation | null;
time: number;
position: number;
completions: number;
bonusMultiplier: number;
points: number;
pointsRaised?: boolean;
recordedAt: number | null;
}
export interface HistoryEntry {
recordId: number;
categoryId: number;
mapName: string;
stage: number;
categoryNumber: number;
serverTag: ServerTag;
isLegacy: boolean;
categoryInvalid: boolean;
time: number;
previousTime: number | null;
improvedBy: number | null;
recordedAt: number | null;
isInvalid: boolean;
invalidation?: Invalidation | null;
categoryInvalidation?: Invalidation | null;
isCurrentBest: boolean;
}
export interface AuthUser {
steamID: string;
steamID64: string | null;
name: string;
role: "admin" | "staff";
expiresAt: number;
}
export interface ApiErrorBody {
statusCode: number;
errorMessage: string;
}
// Page sizes used by the backend.
export const LEADERBOARD_PAGE = 100;
export const CATEGORY_PAGE = 75;
export const PLAYER_MAPS_PAGE = 50;
export const HISTORY_PAGE = 50;
@@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { api, ApiError, getToken, setToken } from "./api/client";
import type { AuthUser } from "./api/types";
interface AuthState {
user: AuthUser | null;
signOut: () => void;
/** Called when an admin request comes back 401 (session expired). */
expired: () => void;
}
const AuthContext = createContext<AuthState>({ user: null, signOut: () => {}, expired: () => {} });
const LOGIN_ERRORS: Record<string, string> = {
not_admin: "That Steam account is not a SourceBans admin, so it cannot sign in here.",
steam_verification_failed: "Steam sign-in could not be verified. Please try again.",
admin_check_unavailable: "The admin check is unavailable right now. Please try again later.",
};
/**
* After Steam sign-in the backend sends the browser to "<site>/#token=..."
* or "<site>/#loginError=...". Pick that up before the router sees the hash,
* then clean the address bar.
*/
export function captureLoginRedirect(): string | null {
const hash = window.location.hash;
const clean = () => window.history.replaceState(null, "", window.location.pathname + window.location.search + "#/");
if (hash.startsWith("#token=")) {
setToken(decodeURIComponent(hash.slice("#token=".length)));
clean();
return null;
}
if (hash.startsWith("#loginError=")) {
const code = decodeURIComponent(hash.slice("#loginError=".length));
clean();
return LOGIN_ERRORS[code] ?? "Sign-in failed.";
}
return null;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
if (!getToken()) return;
let cancelled = false;
api
.me()
.then((u) => !cancelled && setUser(u))
.catch((e) => {
if (e instanceof ApiError && e.status === 401) setToken(null);
});
return () => {
cancelled = true;
};
}, []);
const signOut = useCallback(() => {
setToken(null);
setUser(null);
}, []);
const value = useMemo(() => ({ user, signOut, expired: signOut }), [user, signOut]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
return useContext(AuthContext);
}
@@ -0,0 +1,67 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, ApiError } from "../api/client";
import { useAuth } from "../auth";
/**
* Admin-only button that marks a record or a whole category invalid (or
* restores it). Asks for confirmation first, then refreshes every cached
* view, because points and positions change everywhere.
*/
export function AdminToggle({
kind,
id,
invalid,
what,
}: {
kind: "record" | "category";
id: number;
/** Current state. */
invalid: boolean;
/** Shown in the confirmation, e.g. "jenz's 12.345 on Category 2". */
what: string;
}) {
const { user, expired } = useAuth();
const qc = useQueryClient();
const [confirming, setConfirming] = useState(false);
const m = useMutation({
mutationFn: () => (kind === "record" ? api.setRecordInvalid(id, !invalid) : api.setCategoryInvalid(id, !invalid)),
onSuccess: () => {
setConfirming(false);
qc.invalidateQueries();
},
onError: (e) => {
if (e instanceof ApiError && e.status === 401) expired();
},
});
if (!user) return null;
const action = invalid ? "Restore" : "Invalidate";
if (!confirming) {
return (
<button
className={invalid ? "btn btn-sm btn-ghost" : "btn btn-sm btn-danger-ghost"}
onClick={() => setConfirming(true)}
title={invalid ? `Make this ${kind} count again` : `Mark this ${kind} as invalid (no points)`}
>
{action}
</button>
);
}
return (
<span className="confirm" role="group" aria-label={`${action} ${what}?`}>
<span className="confirm-text">
{action} {what}?
</span>
<button className={invalid ? "btn btn-sm" : "btn btn-sm btn-danger"} onClick={() => m.mutate()} disabled={m.isPending}>
{m.isPending ? "Saving…" : `Yes, ${action.toLowerCase()}`}
</button>
<button className="btn btn-sm btn-ghost" onClick={() => setConfirming(false)} disabled={m.isPending}>
Cancel
</button>
{m.isError && <span className="error-text">{(m.error as Error).message}</span>}
</span>
);
}
@@ -0,0 +1,188 @@
import { Link } from "react-router-dom";
import { SERVERS, type Invalidation, type ServerTag } from "../api/types";
import { bonusLabel, formatDate, formatNumber, serverLabel, timeAgo } from "../format";
/** Hide an image that failed to load instead of showing the browser's broken-image icon. */
export function hideBroken(e: React.SyntheticEvent<HTMLImageElement>) {
e.currentTarget.style.visibility = "hidden";
}
export function PlayerCell({
steamID,
name,
avatar,
badges = [],
}: {
steamID: string;
name: string;
avatar?: string;
badges?: string[];
}) {
return (
<Link to={`/player/${encodeURIComponent(steamID)}`} className="player-cell">
{avatar && <img src={avatar} alt="" className="avatar" loading="lazy" width={28} height={28} onError={hideBroken} />}
<span className="player-name">{name}</span>
{badges.length > 0 && (
<span className="badges" aria-hidden="true">
{badges.slice(0, 3).map((b) => (
<img key={b} src={b} alt="" loading="lazy" onError={hideBroken} />
))}
</span>
)}
</Link>
);
}
/** #1-#3 get medal colors; the number is always shown so color is never the only signal. */
export function Rank({ value }: { value: number }) {
const cls = value === 1 ? "rank gold" : value === 2 ? "rank silver" : value === 3 ? "rank bronze" : "rank";
return <span className={cls}>#{value}</span>;
}
export function ServerSwitch({ value, onChange }: { value: ServerTag; onChange: (s: ServerTag) => void }) {
return (
<div className="segmented" role="group" aria-label="Server">
{SERVERS.map((s) => (
<button key={s} aria-pressed={value === s} className={value === s ? "on" : ""} onClick={() => onChange(s)}>
{serverLabel(s)}
</button>
))}
</div>
);
}
export function ServerTagChip({ tag }: { tag: string }) {
return <span className={`chip chip-server chip-${tag}`}>{serverLabel(tag)}</span>;
}
export function LegacyChip() {
return (
<span className="chip chip-legacy" title="Migrated from the old racetimer (CLASSIC RACETIMER). Points are divided by 10.">
Classic
</span>
);
}
export function InvalidChip({ label = "Invalid" }: { label?: string }) {
return (
<span className="chip chip-invalid" title="Marked invalid by an admin. It does not give points.">
⚠ {label}
</span>
);
}
/**
* "Invalidated by jenz · 30 Sep 2026, 15:20". Shown to everybody.
* Older rows flagged before the admin was recorded say so instead.
*/
export function InvalidatedBy({
info,
what = "Invalidated",
className = "",
}: {
info: Invalidation | null | undefined;
what?: string;
className?: string;
}) {
const name = info?.name ?? info?.steamID ?? null;
const who = name ? (
info?.steamID64 ? (
<a href={`https://steamcommunity.com/profiles/${info.steamID64}`} target="_blank" rel="noreferrer" title={info.steamID ?? undefined}>
{name}
</a>
) : (
<span title={info?.steamID ?? undefined}>{name}</span>
)
) : (
<span>an admin</span>
);
return (
<span className={`invalidated-by ${className}`}>
{what} by {who}
{info?.at != null ? (
<>
{" · "}
<time dateTime={new Date(info.at * 1000).toISOString()}>{formatDate(info.at)}</time>
</>
) : (
!name && <span className="muted"> (before this was recorded)</span>
)}
</span>
);
}
const RAISED_TITLE =
"Raised so small Classic boards stay fair: every time that earns points gets at least 1, and the top 10 places are at least 1 point apart.";
/** Points, with a small ↑ when the small-Classic-board rules raised them. */
export function PointsValue({ points, raised }: { points: number; raised?: boolean }) {
if (!raised) return <>{formatNumber(points)}</>;
return (
<span title={RAISED_TITLE}>
{formatNumber(points)}
<span className="raised-mark" aria-label="raised"> ↑</span>
</span>
);
}
export function BonusChip({
multiplier,
position,
completions,
}: {
multiplier: number;
position: number;
completions: number;
}) {
if (multiplier <= 1.0001) return <span className="muted">–</span>;
const band = bonusLabel(position, completions);
return (
<span className="chip chip-bonus" title={`Points are multiplied by ${multiplier.toFixed(2)} for this position`}>
×{Number(multiplier.toFixed(2))}
{band && <span className="chip-bonus-band"> {band}</span>}
</span>
);
}
export function RecordedAt({ at, legacy }: { at: number | null; legacy: boolean }) {
if (legacy || at === null) {
return (
<span className="muted" title="Set on the old racetimer; the exact date is unknown.">
Legacy record
</span>
);
}
return (
<time className="date" dateTime={new Date(at * 1000).toISOString()} title={timeAgo(at)}>
{formatDate(at)}
</time>
);
}
export function CategoryName({ number, legacy }: { number: number; legacy: boolean }) {
return (
<span className="cat-name">
Category {number}
{legacy && <LegacyChip />}
</span>
);
}
export function LoadMore({
hasMore,
loading,
onClick,
}: {
hasMore: boolean;
loading: boolean;
onClick: () => void;
}) {
if (!hasMore) return null;
return (
<div className="loadmore">
<button className="btn" onClick={onClick} disabled={loading}>
{loading ? "Loading…" : "Load more"}
</button>
</div>
);
}
@@ -0,0 +1,93 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../auth";
import { steamLoginUrl } from "../api/client";
export function Layout({
children,
notice,
onDismissNotice,
}: {
children: ReactNode;
notice: string | null;
onDismissNotice: () => void;
}) {
const { user, signOut } = useAuth();
const navigate = useNavigate();
const [q, setQ] = useState("");
const submit = (e: FormEvent) => {
e.preventDefault();
const text = q.trim();
if (text) navigate(`/search?q=${encodeURIComponent(text)}`);
};
return (
<div className="app">
<header className="topbar">
<div className="topbar-inner">
<Link to="/leaderboard" className="brand" aria-label="UNLOZE Racetimer home">
<span className="brand-mark" aria-hidden="true">⏱</span>
<span className="brand-name">
UNLOZE <span className="brand-sub">Racetimer</span>
</span>
</Link>
<nav className="mainnav" aria-label="Main">
<NavLink to="/leaderboard">Leaderboard</NavLink>
<NavLink to="/maps">Maps</NavLink>
<NavLink to="/points">Points</NavLink>
</nav>
<form className="search" role="search" onSubmit={submit}>
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search players or maps"
aria-label="Search players or maps"
/>
</form>
</div>
</header>
{user && (
<div className="adminbar" role="status">
<div className="container adminbar-inner">
<span>
Signed in as <strong>{user.name}</strong> ({user.role}). Admin controls are shown on map boards and
profiles.
</span>
<button className="btn btn-ghost btn-sm" onClick={signOut}>
Sign out
</button>
</div>
</div>
)}
{notice && (
<div className="container">
<div className="alert alert-danger" role="alert">
<span>{notice}</span>
<button className="btn btn-ghost btn-sm" onClick={onDismissNotice}>
Dismiss
</button>
</div>
</div>
)}
<main className="container main">{children}</main>
<footer className="footer">
<div className="container footer-inner">
<span>
UNLOZE Zombie Escape racetimer · <a href="https://unloze.com">unloze.com</a>
</span>
{!user && (
<a className="footer-admin" href={steamLoginUrl}>
Admin sign-in with Steam
</a>
)}
</div>
</footer>
</div>
);
}
@@ -0,0 +1,45 @@
import { Link } from "react-router-dom";
import { ApiError } from "../api/client";
export function Loading({ label = "Loading…" }: { label?: string }) {
return (
<div className="state" role="status" aria-live="polite">
<span className="spinner" aria-hidden="true" />
<span>{label}</span>
</div>
);
}
export function ErrorBox({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
const message = error instanceof Error ? error.message : "Something went wrong.";
return (
<div className="alert alert-danger" role="alert">
<span>{message}</span>
{onRetry && (
<button className="btn btn-ghost btn-sm" onClick={onRetry}>
Try again
</button>
)}
</div>
);
}
export function isNotFound(error: unknown): boolean {
return error instanceof ApiError && error.status === 404;
}
export function Empty({ children }: { children: React.ReactNode }) {
return <div className="state empty">{children}</div>;
}
export function NotFound({ what = "page" }: { what?: string }) {
return (
<div className="state empty">
<h1>Not found</h1>
<p>This {what} does not exist.</p>
<Link to="/leaderboard" className="btn">
Back to the leaderboard
</Link>
</div>
);
}
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { HistoryEntry } from "../api/types";
import { formatDate, formatTime } from "../format";
const H = 220;
const PAD = { top: 16, right: 20, bottom: 30, left: 64 };
function niceTicks(min: number, max: number, count = 4): number[] {
const span = max - min || 1;
const raw = span / count;
const pow = Math.pow(10, Math.floor(Math.log10(raw)));
const step = [1, 2, 2.5, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? raw;
const start = Math.ceil(min / step) * step;
const out: number[] = [];
for (let v = start; v <= max + 1e-9; v += step) out.push(Math.round(v * 1000) / 1000);
return out;
}
/**
* A player's best time in one category over time (lower is better), drawn
* as a step line: the best time stays flat until the next improvement.
* Invalidated runs and legacy records (no date) are left out.
*/
export function TimeChart({ entries }: { entries: HistoryEntry[] }) {
const points = useMemo(
() =>
entries
.filter((e) => !e.isInvalid && e.recordedAt !== null)
.map((e) => ({ t: e.recordedAt as number, v: e.time, e }))
.sort((a, b) => a.t - b.t),
[entries],
);
const [hover, setHover] = useState<number | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
// Draw at the real pixel width so text and lines keep their size on any screen.
const [W, setW] = useState(640);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setW(Math.max(280, Math.round(el.clientWidth))));
ro.observe(el);
return () => ro.disconnect();
}, []);
if (points.length < 2) return null;
const t0 = points[0].t;
const t1 = Math.max(points[points.length - 1].t, t0 + 1);
const vMin = Math.min(...points.map((p) => p.v));
const vMax = Math.max(...points.map((p) => p.v));
const pad = (vMax - vMin) * 0.12 || 1;
const yMin = vMin - pad;
const yMax = vMax + pad;
const x = (t: number) => PAD.left + ((t - t0) / (t1 - t0)) * (W - PAD.left - PAD.right);
const y = (v: number) => PAD.top + ((v - yMin) / (yMax - yMin)) * (H - PAD.top - PAD.bottom);
let d = `M${x(points[0].t)},${y(points[0].v)}`;
for (let i = 1; i < points.length; i++) {
d += ` H${x(points[i].t)} V${y(points[i].v)}`;
}
const ticks = niceTicks(yMin, yMax);
const dateTicks = [t0, (t0 + t1) / 2, t1];
const onMove = (ev: React.MouseEvent<SVGSVGElement>) => {
const svg = svgRef.current;
if (!svg) return;
const r = svg.getBoundingClientRect();
const px = ((ev.clientX - r.left) / r.width) * W;
let best = 0;
for (let i = 1; i < points.length; i++) {
if (Math.abs(x(points[i].t) - px) < Math.abs(x(points[best].t) - px)) best = i;
}
setHover(best);
};
const h = hover === null ? null : points[hover];
const tipX = h ? x(h.t) : 0;
// Keep the tooltip inside the chart near the edges.
const tipShift = tipX < 90 ? "0%" : tipX > W - 90 ? "-100%" : "-50%";
return (
<figure className="chart">
<figcaption className="chart-title">Best time over time (faster times are higher)</figcaption>
<div className="chart-wrap" ref={wrapRef}>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
width={W}
height={H}
role="img"
aria-label={`Best time went from ${formatTime(points[0].v)} to ${formatTime(points[points.length - 1].v)}`}
onMouseMove={onMove}
onMouseLeave={() => setHover(null)}
>
{ticks.map((v) => (
<g key={v}>
<line x1={PAD.left} x2={W - PAD.right} y1={y(v)} y2={y(v)} className="chart-grid" />
<text x={PAD.left - 8} y={y(v)} className="chart-axis" textAnchor="end" dominantBaseline="middle">
{formatTime(v)}
</text>
</g>
))}
{dateTicks.map((t, i) => (
<text
key={t}
x={x(t)}
y={H - 8}
className="chart-axis"
textAnchor={i === 0 ? "start" : i === 2 ? "end" : "middle"}
>
{new Date(t * 1000).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
</text>
))}
<path d={d} className="chart-line" />
{h && <line x1={x(h.t)} x2={x(h.t)} y1={PAD.top} y2={H - PAD.bottom} className="chart-crosshair" />}
{points.map((p, i) => (
<circle key={p.e.recordId} cx={x(p.t)} cy={y(p.v)} r={hover === i ? 6 : 4} className="chart-dot" />
))}
{/* Direct label on the latest point only. */}
<text
x={x(points[points.length - 1].t) - 8}
y={y(points[points.length - 1].v) - 10}
className="chart-label"
textAnchor="end"
>
{formatTime(points[points.length - 1].v)}
</text>
</svg>
{h && (
<div
className="chart-tip"
style={{ left: tipX, top: y(h.v), transform: `translate(${tipShift}, calc(-100% - 12px))` }}
role="status"
>
<strong>{formatTime(h.v)}</strong>
{h.e.improvedBy !== null && <span className="good"> −{formatTime(h.e.improvedBy)}</span>}
<br />
<span className="muted">{formatDate(h.t)}</span>
</div>
)}
</div>
</figure>
);
}
@@ -0,0 +1,74 @@
const number = new Intl.NumberFormat("en-US");
/** 12.345 -> "12.345", 83.2 -> "1:23.200", 3723.5 -> "1:02:03.500". */
export function formatTime(seconds: number): string {
const ms = Math.round(seconds * 1000);
const h = Math.floor(ms / 3_600_000);
const m = Math.floor((ms % 3_600_000) / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
const rest = String(ms % 1000).padStart(3, "0");
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${rest}`;
if (m > 0) return `${m}:${String(s).padStart(2, "0")}.${rest}`;
return `${s}.${rest}`;
}
/** Signed difference, e.g. "+1.204" or "−0.500". */
export function formatDelta(seconds: number): string {
const sign = seconds < 0 ? "−" : "+";
return sign + formatTime(Math.abs(seconds));
}
export function formatNumber(n: number): string {
return number.format(n);
}
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31_536_000],
["month", 2_592_000],
["week", 604_800],
["day", 86_400],
["hour", 3_600],
["minute", 60],
];
/** Unix seconds -> "3 weeks ago". */
export function timeAgo(unixSeconds: number, now = Date.now() / 1000): string {
// Never show "in 3 minutes" when a server clock is slightly ahead.
const diff = Math.min(0, unixSeconds - now);
for (const [unit, size] of UNITS) {
if (Math.abs(diff) >= size) return rtf.format(Math.round(diff / size), unit);
}
return "just now";
}
/** Unix seconds -> "27 Sep 2026, 14:03:09" in the viewer's own time zone. */
export function formatDate(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
export function serverLabel(tag: string): string {
return tag.toUpperCase();
}
/** Which bonus band a position is in, for the label next to the multiplier. */
export function bonusLabel(position: number, completions: number): string | null {
if (completions < 100) return null;
const x = (position - 1) / completions;
if (x < 0.01) return "Top 1%";
if (x < 0.05) return "Top 5%";
return null;
}
/** How many players on a board get points (half from 200 completions on). */
export function pointedCount(completions: number): number {
return completions < 200 ? completions : Math.floor(completions / 2);
}
@@ -0,0 +1,37 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HashRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider, captureLoginRedirect } from "./auth";
import { ServerProvider } from "./server";
import { ApiError } from "./api/client";
import "./styles.css";
const loginError = captureLoginRedirect();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// The backend recalculates every 30 minutes, so a few minutes of caching is plenty.
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
retry: (count, error) => !(error instanceof ApiError && error.status >= 400 && error.status < 500) && count < 2,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ServerProvider>
{/* Hash URLs (#/player/...) keep the old site's links working. */}
<HashRouter>
<App loginError={loginError} />
</HashRouter>
</ServerProvider>
</AuthProvider>
</QueryClientProvider>
</StrictMode>,
);
@@ -0,0 +1,68 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import { LEADERBOARD_PAGE } from "../api/types";
import { LoadMore, PlayerCell, Rank, ServerSwitch } from "../components/Bits";
import { Empty, ErrorBox, Loading } from "../components/States";
import { formatNumber, serverLabel } from "../format";
import { useServer } from "../server";
export function LeaderboardPage() {
const { server, setServer } = useServer();
const q = useInfiniteQuery({
queryKey: ["leaderboard", server],
queryFn: ({ pageParam }) => api.leaderboard(server, pageParam),
initialPageParam: 0,
getNextPageParam: (last, all) => (last.length < LEADERBOARD_PAGE ? undefined : all.length * LEADERBOARD_PAGE),
});
const players = q.data?.pages.flat() ?? [];
return (
<>
<section className="page-head">
<div>
<h1>{serverLabel(server)} leaderboard</h1>
<p className="lead">
Players ranked by racetimer points on {serverLabel(server)}. ZE1 and ZE2 are ranked separately.{" "}
<Link to="/points">How points work</Link>
</p>
</div>
<ServerSwitch value={server} onChange={setServer} />
</section>
{q.isPending && <Loading />}
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
{q.isSuccess && players.length === 0 && <Empty>No one has a time on {serverLabel(server)} yet.</Empty>}
{players.length > 0 && (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th className="num">Rank</th>
<th>Player</th>
<th className="num">Points</th>
<th className="num hide-sm">Records</th>
</tr>
</thead>
<tbody>
{players.map((p) => (
<tr key={p.steamID}>
<td className="num">
<Rank value={p.Rank} />
</td>
<td>
<PlayerCell steamID={p.steamID} name={p.name} avatar={p.Avatar} badges={p.UrlBanners} />
</td>
<td className="num strong">{formatNumber(p.PlayerPoints)}</td>
<td className="num hide-sm">{formatNumber(p.Times)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
</>
);
}
@@ -0,0 +1,340 @@
import { Fragment } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import { CATEGORY_PAGE, type Category, type CategoryBoard, type Stage } from "../api/types";
import {
BonusChip,
InvalidatedBy,
InvalidChip,
LegacyChip,
LoadMore,
PlayerCell,
PointsValue,
Rank,
RecordedAt,
ServerTagChip,
} from "../components/Bits";
import { AdminToggle } from "../components/AdminToggle";
import { Empty, ErrorBox, isNotFound, Loading, NotFound } from "../components/States";
import { formatDelta, formatNumber, formatTime, pointedCount, serverLabel } from "../format";
export function MapPage() {
const { mapname = "" } = useParams();
const [params, setParams] = useSearchParams();
const map = useQuery({ queryKey: ["map", mapname.toLowerCase()], queryFn: () => api.map(mapname) });
if (map.isPending) return <Loading />;
if (map.isError) {
return isNotFound(map.error) ? <NotFound what="map" /> : <ErrorBox error={map.error} onRetry={() => map.refetch()} />;
}
const m = map.data;
const stageNo = Number(params.get("stage")) || m.stages[0]?.stage;
const stage = m.stages.find((s) => s.stage === stageNo) ?? m.stages[0];
if (!stage) return <Empty>This map has no categories yet.</Empty>;
const catId = Number(params.get("category"));
const category =
stage.categories.find((c) => c.id === catId) ??
stage.categories.find((c) => !c.isInvalid) ??
stage.categories[0];
const select = (s: number, c?: number) => {
const next = new URLSearchParams();
next.set("stage", String(s));
if (c) next.set("category", String(c));
setParams(next);
};
return (
<>
<section className="page-head">
<div>
<h1 className="map-title">{m.mapName}</h1>
<p className="lead">
{m.stages.length === 1 ? "1 stage" : `${m.stages.length} stages`}
{m.allCategoriesInvalid && (
<>
{" "}
· <InvalidChip label="Every category on this map is invalid" />
</>
)}
</p>
</div>
</section>
{m.stages.length > 1 && (
<div className="tabs" role="tablist" aria-label="Stages">
{m.stages.map((s) => (
<button
key={s.stage}
role="tab"
aria-selected={s.stage === stage.stage}
className={s.stage === stage.stage ? "on" : ""}
onClick={() => select(s.stage)}
>
Stage {s.stage}
{s.allCategoriesInvalid && <span className="tab-flag" title="Every category of this stage is invalid"> ⚠</span>}
</button>
))}
</div>
)}
{stage.allCategoriesInvalid && (
<div className="alert alert-warn">
Every category of stage {stage.stage} has been marked invalid by an admin, so this stage gives no points.
</div>
)}
<CategoryPicker stage={stage} selected={category} onSelect={(c) => select(stage.stage, c)} />
<CategoryDetails stage={stage} category={category} />
<Board category={category} />
</>
);
}
function CategoryPicker({
stage,
selected,
onSelect,
}: {
stage: Stage;
selected: Category;
onSelect: (id: number) => void;
}) {
if (stage.categories.length <= 1) return null;
return (
<div className="cat-picker" role="tablist" aria-label="Categories">
{stage.categories.map((c) => (
<button
key={c.id}
role="tab"
aria-selected={c.id === selected.id}
className={`cat-card ${c.id === selected.id ? "on" : ""} ${c.isInvalid ? "is-invalid" : ""}`}
onClick={() => onSelect(c.id)}
>
<span className="cat-card-title">
Category {c.categoryNumber} <ServerTagChip tag={c.serverTag} />
</span>
<span className="cat-card-sub">
{formatNumber(c.completions)} records
{c.fastestTime !== null && <> · best {formatTime(c.fastestTime)}</>}
</span>
<span className="cell-chips">
{c.isLegacy && <LegacyChip />}
{c.isInvalid && <InvalidChip />}
</span>
{c.isInvalid && <InvalidatedBy info={c.invalidation} className="small" />}
</button>
))}
</div>
);
}
/** Server settings of the selected category; values that differ between this stage's categories are highlighted. */
function CategoryDetails({ stage, category }: { stage: Stage; category: Category }) {
const others = stage.categories.filter((c) => c.id !== category.id && !c.isLegacy);
const differs = (name: string, value: string) =>
others.some((o) => {
const v = o.cvars.find((x) => x.name === name);
return v !== undefined && v.value !== value;
});
return (
<section className="panel">
<div className="panel-head">
<h2>
Category {category.categoryNumber} <ServerTagChip tag={category.serverTag} />
{category.isLegacy && <LegacyChip />}
{category.isInvalid && <InvalidChip />}
</h2>
<AdminToggle
kind="category"
id={category.id}
invalid={category.isInvalid}
what={`category ${category.categoryNumber} of stage ${category.stage}`}
/>
</div>
{category.isInvalid && (
<div className="alert alert-warn">
<span>
<strong>
<InvalidatedBy info={category.invalidation} what="Category marked invalid" />
</strong>
. Its times are still listed, but none of them give points.
</span>
</div>
)}
{category.isLegacy ? (
<p className="muted">
Times from the old racetimer (CLASSIC RACETIMER). The server settings were not recorded back then, the exact dates are
unknown, and points from this category are divided by 10.
</p>
) : category.cvars.length > 0 ? (
<>
<p className="hint">
Server settings when these times were set.
{others.length > 0 && " Highlighted values differ from another category of this stage."}
</p>
<dl className="cvars">
{category.cvars.map((c) => {
const diff = differs(c.name, c.value);
return (
<div key={c.name} className={diff ? "cvar diff" : "cvar"}>
<dt>{c.name}</dt>
<dd>
{c.value}
{diff && <span className="sr-only"> (differs)</span>}
</dd>
</div>
);
})}
</dl>
</>
) : (
<p className="muted mono">{category.serverCvars}</p>
)}
</section>
);
}
function Board({ category }: { category: Category }) {
const q = useInfiniteQuery({
queryKey: ["category", category.id],
queryFn: ({ pageParam }) => api.category(category.id, pageParam),
initialPageParam: 0,
getNextPageParam: (last: CategoryBoard, all) =>
last.entries.length < CATEGORY_PAGE ? undefined : all.length * CATEGORY_PAGE,
});
const pages = q.data?.pages ?? [];
const entries = pages.flatMap((p) => p.entries);
const hasInvalid = entries.some((e) => e.isInvalid);
const hasRaised = entries.some((e) => e.pointsRaised);
const fresh = pages[0]?.category ?? category;
const cut = fresh.completions >= 200 ? pointedCount(fresh.completions) : null;
const best = entries.find((e) => !e.isInvalid)?.time;
if (q.isPending) return <Loading />;
if (q.isError) return <ErrorBox error={q.error} onRetry={() => q.refetch()} />;
// Position of the previous valid row, for drawing the points cut-off line.
let lastValidPosition = 0;
return (
<section>
<div className="board-head">
<h2>Leaderboard</h2>
<span className="muted">
{formatNumber(fresh.completions)} {fresh.completions === 1 ? "record" : "records"}
{cut !== null && <> · top {formatNumber(cut)} earn points</>}
{fresh.completions >= 100 && <> · top 5% get a bonus</>}
</span>
</div>
{hasInvalid && (
<p className="hint">
Rows marked <InvalidChip /> were invalidated by an admin (shown under the name, with the date). They are listed where
their time would place them, but they have no position and give no points; the player's previous valid time counts
instead.
</p>
)}
{hasRaised && (
<p className="hint">
<span className="raised-mark">↑</span> Classic points are divided by 10, which on a small board would give almost
everyone the same points. So every time that earns points gets at least 1, and each of the top 10 places gets at least 1
point more than the place below it. <Link to="/points">How points work</Link>
</p>
)}
{entries.length === 0 ? (
<Empty>No times in this category yet.</Empty>
) : (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th className="num">#</th>
<th>Player</th>
<th className="num">Time</th>
<th className="num hide-sm">Gap</th>
<th className="hide-sm">Bonus</th>
<th className="num">Points</th>
<th className="hide-sm">Set</th>
<th className="hide-sm" />
</tr>
</thead>
<tbody>
{entries.map((e) => {
const showCut =
!e.isInvalid && cut !== null && lastValidPosition > 0 && lastValidPosition <= cut && e.position > cut;
if (!e.isInvalid) lastValidPosition = e.position;
const what = `${e.name}'s ${formatTime(e.time)}`;
return (
<Fragment key={e.recordId}>
{showCut && (
<tr className="cut-row">
<td colSpan={8}>Points cut-off: only the faster half of this board earns points</td>
</tr>
)}
{e.isInvalid ? (
<tr className="row-invalid run-invalid">
<td className="num muted" title="Invalidated runs have no position">
–
</td>
<td>
<div className="cell-with-chip">
<PlayerCell steamID={e.steamID} name={e.name} avatar={e.avatar} badges={e.badgesUrls} />
<InvalidChip />
</div>
<InvalidatedBy info={e.invalidation} className="small" />
</td>
<td className="num mono strike">{formatTime(e.time)}</td>
<td className="num mono muted hide-sm" />
<td className="hide-sm" />
<td className="num muted">0</td>
<td className="hide-sm">
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
</td>
<td className="hide-sm">
<AdminToggle kind="record" id={e.recordId} invalid what={what} />
</td>
</tr>
) : (
<tr>
<td className="num">
<Rank value={e.position} />
</td>
<td>
<PlayerCell steamID={e.steamID} name={e.name} avatar={e.avatar} badges={e.badgesUrls} />
</td>
<td className="num mono strong">{formatTime(e.time)}</td>
<td className="num mono muted hide-sm">
{best !== undefined && e.time > best ? formatDelta(e.time - best) : ""}
</td>
<td className="hide-sm">
<BonusChip multiplier={e.bonusMultiplier} position={e.position} completions={fresh.completions} />
</td>
<td className="num">
<PointsValue points={e.points} raised={e.pointsRaised} />
</td>
<td className="hide-sm">
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
</td>
<td className="hide-sm">
<AdminToggle kind="record" id={e.recordId} invalid={false} what={what} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
<p className="hint">
Showing {serverLabel(fresh.serverTag)} times for {fresh.mapName}, stage {fresh.stage}, category {fresh.categoryNumber}.
</p>
</section>
);
}
@@ -0,0 +1,92 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import type { MapInfo } from "../api/types";
import { InvalidChip, ServerTagChip } from "../components/Bits";
import { Empty, ErrorBox, Loading } from "../components/States";
import { formatNumber } from "../format";
export function mapSummary(m: MapInfo) {
const cats = m.stages.flatMap((s) => s.categories);
const servers = Array.from(new Set(cats.map((c) => c.serverTag))).sort();
const completions = cats.reduce((sum, c) => sum + c.completions, 0);
return { categories: cats.length, servers, completions };
}
export function MapsTable({ maps }: { maps: MapInfo[] }) {
return (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Map</th>
<th className="num">Stages</th>
<th className="num hide-sm">Categories</th>
<th className="hide-sm">Servers</th>
<th className="num">Records</th>
</tr>
</thead>
<tbody>
{maps.map((m) => {
const s = mapSummary(m);
return (
<tr key={m.mapName} className={m.allCategoriesInvalid ? "row-invalid" : ""}>
<td>
<Link to={`/map/${encodeURIComponent(m.mapName)}`} className="map-link">
{m.mapName}
</Link>
{m.allCategoriesInvalid && (
<>
{" "}
<InvalidChip label="Disabled" />
</>
)}
</td>
<td className="num">{m.stages.length}</td>
<td className="num hide-sm">{s.categories}</td>
<td className="hide-sm">
<span className="cell-chips">
{s.servers.map((t) => (
<ServerTagChip key={t} tag={t} />
))}
</span>
</td>
<td className="num">{formatNumber(s.completions)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export function MapsPage() {
const q = useQuery({ queryKey: ["allmaps"], queryFn: api.allMaps });
const [text, setText] = useState("");
const maps = (q.data ?? []).filter((m) => m.mapName.toLowerCase().includes(text.toLowerCase()));
return (
<>
<section className="page-head">
<div>
<h1>Maps</h1>
<p className="lead">Every map with a racetimer. Open a map to see its stages, categories and leaderboards.</p>
</div>
<input
className="input"
type="search"
placeholder="Filter maps"
value={text}
onChange={(e) => setText(e.target.value)}
aria-label="Filter maps"
/>
</section>
{q.isPending && <Loading />}
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
{q.isSuccess && maps.length === 0 && <Empty>No maps match “{text}”.</Empty>}
{maps.length > 0 && <MapsTable maps={maps} />}
</>
);
}
@@ -0,0 +1,349 @@
import { useMemo, useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import { HISTORY_PAGE, PLAYER_MAPS_PAGE, SERVERS, type HistoryEntry, type Player, type PlayerMapRow } from "../api/types";
import {
hideBroken,
BonusChip,
CategoryName,
InvalidatedBy,
InvalidChip,
PointsValue,
LoadMore,
RecordedAt,
ServerTagChip,
} from "../components/Bits";
import { TimeChart } from "../components/TimeChart";
import { AdminToggle } from "../components/AdminToggle";
import { Empty, ErrorBox, isNotFound, Loading, NotFound } from "../components/States";
import { formatNumber, formatTime, serverLabel } from "../format";
import { useServer } from "../server";
type Tab = "records" | "history";
export function PlayerPage() {
const { steamid = "" } = useParams();
const { server } = useServer();
const [params, setParams] = useSearchParams();
const tab: Tab = params.get("tab") === "history" ? "history" : "records";
const categoryId = Number(params.get("category")) || undefined;
const player = useQuery({ queryKey: ["player", steamid, server], queryFn: () => api.player(steamid, server) });
if (player.isPending) return <Loading />;
if (player.isError) {
return isNotFound(player.error) ? (
<NotFound what="player" />
) : (
<ErrorBox error={player.error} onRetry={() => player.refetch()} />
);
}
const p = player.data;
const setTab = (t: Tab, category?: number) => {
const next = new URLSearchParams();
if (t === "history") next.set("tab", "history");
if (category) next.set("category", String(category));
setParams(next, { replace: false });
};
return (
<>
<ProfileHeader p={p} />
<div className="tabs" role="tablist">
<button role="tab" aria-selected={tab === "records"} className={tab === "records" ? "on" : ""} onClick={() => setTab("records")}>
Records
</button>
<button role="tab" aria-selected={tab === "history"} className={tab === "history" ? "on" : ""} onClick={() => setTab("history")}>
Improvement history
</button>
</div>
{tab === "records" ? (
<RecordsTab steamid={p.steamID} onShowHistory={(id) => setTab("history", id)} />
) : (
<HistoryTab steamid={p.steamID} categoryId={categoryId} onCategory={(id) => setTab("history", id)} />
)}
</>
);
}
function ProfileHeader({ p }: { p: Player }) {
const { server } = useServer();
return (
<section className="profile">
<img src={p.Avatar} alt="" className="profile-avatar" width={96} height={96} onError={hideBroken} />
<div className="profile-main">
<h1>{p.name}</h1>
<div className="profile-ids">
<code>{p.steamID}</code>
{p.steamID64 && (
<a href={`https://steamcommunity.com/profiles/${p.steamID64}`} target="_blank" rel="noreferrer">
Steam profile ↗
</a>
)}
</div>
{p.badges.length > 0 && (
<div className="profile-badges">
{p.badges.map((b) => (
<span key={b.url} className="badge-pill">
<img src={b.url} alt="" onError={hideBroken} />
{b.name}
</span>
))}
</div>
)}
</div>
<div className="stat-row">
{SERVERS.map((s) => {
const st = p.servers[s];
return (
<div key={s} className={`stat ${s === server ? "stat-on" : ""}`}>
<div className="stat-label">{serverLabel(s)} points</div>
<div className="stat-value">{formatNumber(st.points)}</div>
<div className="stat-sub">
{st.times > 0 ? (
<>
Rank #{formatNumber(st.rank)} · {formatNumber(st.times)} records
</>
) : (
"No records yet"
)}
</div>
</div>
);
})}
</div>
</section>
);
}
function RecordsTab({ steamid, onShowHistory }: { steamid: string; onShowHistory: (categoryId: number) => void }) {
const [serverFilter, setServerFilter] = useState<"all" | "ze1" | "ze2">("all");
const [text, setText] = useState("");
const q = useInfiniteQuery({
queryKey: ["playerMaps", steamid],
queryFn: ({ pageParam }) => api.playerMaps(steamid, pageParam),
initialPageParam: 0,
getNextPageParam: (last, all) => (last.length < PLAYER_MAPS_PAGE ? undefined : all.length * PLAYER_MAPS_PAGE),
});
const rows: PlayerMapRow[] = q.data?.pages.flat() ?? [];
const shown = rows.filter(
(r) =>
(serverFilter === "all" || r.serverTag === serverFilter) &&
(text === "" || r.mapName.toLowerCase().includes(text.toLowerCase())),
);
if (q.isPending) return <Loading />;
if (q.isError) return <ErrorBox error={q.error} onRetry={() => q.refetch()} />;
if (rows.length === 0) return <Empty>This player has no records yet.</Empty>;
return (
<>
<div className="toolbar">
<div className="segmented" role="group" aria-label="Server filter">
{(["all", "ze1", "ze2"] as const).map((s) => (
<button key={s} aria-pressed={serverFilter === s} className={serverFilter === s ? "on" : ""} onClick={() => setServerFilter(s)}>
{s === "all" ? "All" : serverLabel(s)}
</button>
))}
</div>
<input
className="input"
type="search"
placeholder="Filter maps"
value={text}
onChange={(e) => setText(e.target.value)}
aria-label="Filter maps"
/>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Map</th>
<th className="num">Time</th>
<th className="num">Place</th>
<th className="hide-sm">Bonus</th>
<th className="num">Points</th>
<th className="hide-sm">Set</th>
<th className="hide-sm" />
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.recordId} className={r.categoryInvalid || r.isInvalid ? "row-invalid" : ""}>
<td className="map-cell">
<Link to={`/map/${encodeURIComponent(r.mapName)}?stage=${r.stage}&category=${r.categoryId}`} className="map-link">
{r.mapName}
</Link>
<div className="map-meta">
<span className="sub">
Stage {r.stage} · <CategoryName number={r.categoryNumber} legacy={r.isLegacy} />
</span>
<ServerTagChip tag={r.serverTag} />
{r.isInvalid && <InvalidChip label="Run invalidated" />}
{r.categoryInvalid && <InvalidChip label="Category invalid" />}
</div>
{r.isInvalid && <InvalidatedBy info={r.invalidation} what="Run invalidated" className="small" />}
{r.categoryInvalid && (
<InvalidatedBy info={r.categoryInvalidation} what="Category invalidated" className="small" />
)}
</td>
<td className={r.isInvalid ? "num mono strike" : "num mono"}>{formatTime(r.time)}</td>
<td className="num">
{r.isInvalid ? (
<span className="muted" title="Invalidated runs have no position">
–
</span>
) : (
<>
#{formatNumber(r.position)}
<span className="sub"> / {formatNumber(r.completions)}</span>
</>
)}
</td>
<td className="hide-sm">
{!r.isInvalid && (
<BonusChip multiplier={r.bonusMultiplier} position={r.position} completions={r.completions} />
)}
</td>
<td className={r.isInvalid ? "num muted" : "num strong"}>
<PointsValue points={r.points} raised={r.pointsRaised} />
</td>
<td className="hide-sm">
<RecordedAt at={r.recordedAt} legacy={r.isLegacy} />
</td>
<td className="hide-sm">
<div className="row-actions">
{!r.isLegacy && (
<button className="btn btn-sm btn-ghost" onClick={() => onShowHistory(r.categoryId)}>
History
</button>
)}
<AdminToggle kind="record" id={r.recordId} invalid={r.isInvalid} what={`this ${formatTime(r.time)} run`} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{shown.length === 0 && <Empty>No records match the filter.</Empty>}
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
</>
);
}
function HistoryTab({
steamid,
categoryId,
onCategory,
}: {
steamid: string;
categoryId?: number;
onCategory: (id?: number) => void;
}) {
const maps = useQuery({
queryKey: ["playerMapsAll", steamid],
// Every category the player has a time in, for the filter drop-down.
queryFn: async () => {
const out: PlayerMapRow[] = [];
for (let offset = 0; ; offset += PLAYER_MAPS_PAGE) {
const page = await api.playerMaps(steamid, offset);
out.push(...page);
if (page.length < PLAYER_MAPS_PAGE) return out;
}
},
});
const q = useInfiniteQuery({
queryKey: ["history", steamid, categoryId ?? 0],
queryFn: ({ pageParam }) => api.playerHistory(steamid, pageParam, categoryId),
initialPageParam: 0,
getNextPageParam: (last, all) => (last.length < HISTORY_PAGE ? undefined : all.length * HISTORY_PAGE),
});
const entries: HistoryEntry[] = q.data?.pages.flat() ?? [];
const options = useMemo(
() =>
(maps.data ?? [])
.filter((r) => !r.isLegacy)
.sort((a, b) => a.mapName.localeCompare(b.mapName) || a.stage - b.stage || a.categoryNumber - b.categoryNumber),
[maps.data],
);
return (
<>
<div className="toolbar">
<label className="field">
<span>Category</span>
<select
className="input"
value={categoryId ?? ""}
onChange={(e) => onCategory(e.target.value ? Number(e.target.value) : undefined)}
>
<option value="">All categories</option>
{options.map((r) => (
<option key={r.categoryId} value={r.categoryId}>
{r.mapName} · Stage {r.stage} · Category {r.categoryNumber} · {serverLabel(r.serverTag)}
</option>
))}
</select>
</label>
</div>
<p className="hint">
Every saved run was a new personal best when it was set. Classic (migrated) records have no date and are listed last.
</p>
{q.isPending && <Loading />}
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
{q.isSuccess && entries.length === 0 && <Empty>No improvements to show.</Empty>}
{categoryId && entries.length > 0 && <TimeChart entries={entries} />}
{entries.length > 0 && (
<ol className="timeline">
{entries.map((e) => (
<li key={e.recordId} className={e.isInvalid ? "tl-invalid" : ""}>
<div className="tl-when">
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
</div>
<div className="tl-body">
<div className="tl-title">
<span className="mono strong">{formatTime(e.time)}</span>
{e.improvedBy !== null && e.improvedBy > 0 && (
<span className="good">
{" "}
improved by {formatTime(e.improvedBy)}s
</span>
)}
{e.previousTime === null && !e.isInvalid && <span className="muted"> first time</span>}
{e.isCurrentBest && <span className="chip chip-best">Current best</span>}
{e.isInvalid && <InvalidChip label="Run invalidated" />}
</div>
<div className="sub">
{!categoryId ? (
<button className="linklike" onClick={() => onCategory(e.categoryId)}>
{e.mapName} · Stage {e.stage} · Category {e.categoryNumber}
</button>
) : (
<>
{e.mapName} · Stage {e.stage} · Category {e.categoryNumber}
</>
)}{" "}
· {serverLabel(e.serverTag)}
{e.previousTime !== null && <> · previous best {formatTime(e.previousTime)}</>}
</div>
{e.isInvalid && <InvalidatedBy info={e.invalidation} what="Run invalidated" className="small" />}
{e.categoryInvalid && (
<InvalidatedBy info={e.categoryInvalidation} what="Category invalidated" className="small" />
)}
</div>
<div className="tl-admin">
<AdminToggle kind="record" id={e.recordId} invalid={e.isInvalid} what={`this ${formatTime(e.time)} run`} />
</div>
</li>
))}
</ol>
)}
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
</>
);
}
@@ -0,0 +1,67 @@
export function PointsPage() {
return (
<article className="prose">
<h1>How points work</h1>
<p className="lead">
Every map stage can have several <strong>categories</strong>: one per set of server settings (gravity, air
acceleration, tickrate and so on) and per server. Each category has its own leaderboard, and each gives points on its
own.
</p>
<h2>Points per category</h2>
<ul>
<li>Only your best valid time in a category counts. Players with the same time share the same place.</li>
<li>
On a board with <em>n</em> records, first place gets <em>n</em> points, second place <em>n − 1</em>, and so on.
</li>
<li>
Once a board has <strong>200 or more records</strong>, only the faster half earns points. With 200 records the top 100
do, with 210 records the top 105.
</li>
</ul>
<h2>Top 5% bonus</h2>
<p>
On boards with at least 100 records, the fastest players get a multiplier that rises smoothly the closer they are to
first place:
</p>
<ul>
<li>4× for first place, sliding down to 2× at the top 1%</li>
<li>2× at the top 1%, sliding down to no bonus at the top 5%</li>
</ul>
<p>The multiplier is shown next to each time on the leaderboards and on your profile.</p>
<h2>Classic records</h2>
<p>
Times from the old racetimer are kept as <strong>Classic</strong> categories. Their points are divided by 10, and they
show as “Legacy record” because the exact date was not saved back then.
</p>
<p>
On small Classic boards, dividing by 10 would give almost everyone the same few points. So two extra rules apply:
</p>
<ul>
<li>
Every time that earns points gets <strong>at least 1 point</strong>.
</li>
<li>
Each of the <strong>top 10 places</strong> gets at least 1 point more than the place below it.
</li>
</ul>
<p>
For example, a Classic board with 9 records gives 9, 8, 7 … 1 points instead of 1, 1, 1, 1, 1, 0, 0, 0, 0. On big
Classic boards the top places are already far apart, so nothing changes there. Points that were raised by these rules are
marked with <span className="raised-mark">↑</span> on the leaderboard.
</p>
<h2>ZE1 and ZE2</h2>
<p>Points and ranks are counted separately for each server. Use the ZE1 / ZE2 switch on the leaderboard.</p>
<h2>Invalid records</h2>
<p>
Admins can mark a single run or a whole category as invalid, for example if it was cheated or the map was broken. An
invalid run gives no points and your previous valid time counts again. An invalid category stays visible but gives no
points to anyone.
</p>
</article>
);
}
@@ -0,0 +1,70 @@
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import { PlayerCell, Rank } from "../components/Bits";
import { Empty, ErrorBox, Loading } from "../components/States";
import { formatNumber, serverLabel } from "../format";
import { useServer } from "../server";
import { MapsTable } from "./MapsPage";
export function SearchPage() {
const [params] = useSearchParams();
const q = (params.get("q") ?? "").trim();
const { server } = useServer();
const players = useQuery({
queryKey: ["searchPlayers", q, server],
queryFn: () => api.searchPlayers(q, server),
enabled: q.length > 0,
});
const maps = useQuery({ queryKey: ["allmaps"], queryFn: api.allMaps, enabled: q.length > 0 });
const mapHits = (maps.data ?? []).filter((m) => m.mapName.toLowerCase().includes(q.toLowerCase()));
if (!q) return <Empty>Type a player name, Steam ID or map name in the search box.</Empty>;
return (
<>
<section className="page-head">
<div>
<h1>Search: “{q}”</h1>
</div>
</section>
<h2>Players</h2>
{players.isPending && <Loading />}
{players.isError && <ErrorBox error={players.error} onRetry={() => players.refetch()} />}
{players.isSuccess && players.data.length === 0 && <Empty>No players found.</Empty>}
{players.isSuccess && players.data.length > 0 && (
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Player</th>
<th className="num">{serverLabel(server)} rank</th>
<th className="num">{serverLabel(server)} points</th>
<th className="hide-sm">Steam ID</th>
</tr>
</thead>
<tbody>
{players.data.map((p) => (
<tr key={p.steamID}>
<td>
<PlayerCell steamID={p.steamID} name={p.name} avatar={p.Avatar} badges={p.UrlBanners} />
</td>
<td className="num">{p.Rank > 0 ? <Rank value={p.Rank} /> : <span className="muted">–</span>}</td>
<td className="num strong">{formatNumber(p.PlayerPoints)}</td>
<td className="hide-sm mono muted">{p.steamID}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<h2>Maps</h2>
{maps.isPending && <Loading />}
{maps.isError && <ErrorBox error={maps.error} onRetry={() => maps.refetch()} />}
{maps.isSuccess && mapHits.length === 0 && <Empty>No maps found.</Empty>}
{mapHits.length > 0 && <MapsTable maps={mapHits} />}
</>
);
}
@@ -0,0 +1,39 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { SERVERS, type ServerTag } from "./api/types";
const KEY = "racetimer.server";
function initial(): ServerTag {
try {
const v = localStorage.getItem(KEY);
if (v && (SERVERS as string[]).includes(v)) return v as ServerTag;
} catch {
/* ignore */
}
return "ze1";
}
interface ServerState {
server: ServerTag;
setServer: (s: ServerTag) => void;
}
const ServerContext = createContext<ServerState>({ server: "ze1", setServer: () => {} });
/** Which server's points (ZE1 or ZE2) the leaderboard and profiles show. Remembered per browser. */
export function ServerProvider({ children }: { children: ReactNode }) {
const [server, setState] = useState<ServerTag>(initial);
const setServer = (s: ServerTag) => {
setState(s);
try {
localStorage.setItem(KEY, s);
} catch {
/* ignore */
}
};
return <ServerContext.Provider value={{ server, setServer }}>{children}</ServerContext.Provider>;
}
export function useServer(): ServerState {
return useContext(ServerContext);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// base "./" lets the built site be served from any folder on the web server.
export default defineConfig({
base: "./",
plugins: [react()],
});