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
+6 -1
View File
@@ -18,6 +18,10 @@ All paths below are under `https://<backend>/racetimer_endpoints-1.0/api/`.
The settings path can also be given with `-Dracetimer.settings=/path/file.json` or the `RACETIMER_SETTINGS` environment variable.
## Category numbers
`categoryNumber` is counted per map and stage. Categories with exactly the same `server_cvars` share a number, so the same settings on ZE1 and ZE2 are both Category 1 and are told apart by `serverTag`. Different settings are numbered in order of their lowest `cvars_hash`. Hidden servers (dev) never take a number away from ze1/ze2.
## Points
Points are calculated per category, from each player's **best valid** time only. Tied times share a position.
@@ -27,7 +31,8 @@ Points are calculated per category, from each player's **best valid** time only.
| Who gets points | Everyone while a board has fewer than 200 completions. From 200 on, only the faster half (200 → top 100, 210 → top 105). |
| Base points | n − (position − 1): the fastest gets n (the number of completions), each position below gets one less. The slower half gets 0, so on big boards points drop from about n/2 straight to 0 at the cut. |
| Bonus multiplier | Only on boards with 100+ completions. 4× at #1, sliding smoothly to 2× at the top-1% mark, then to 1× at the top-5% mark. |
| CLASSIC RACETIMER | Final points ÷ 10. |
| CLASSIC RACETIMER | Final points ÷ 10, rounded. |
| Small-board touch-ups | Everyone who gets points gets at least 1, and each of the top 10 places gets at least 1 point more than the place below it (ties share). This only ever raises points, and in practice only changes Classic boards: a 9-record Classic board gives 9, 8, 7 … 1 instead of 1, 1, 1, 1, 1, 0, 0, 0, 0. Raised entries have `pointsRaised: true`. |
| Invalid category | Still shown with positions, but gives 0 points. The admin and time are public (`invalidation`). |
| Invalid record | Still listed on the board in time order with `isInvalid: true`, position 0 and 0 points. Only that one run is removed from the ranking; the player's previous valid time counts again. The admin and time are public (`invalidation`). |
| Servers | ZE1 and ZE2 points and ranks are separate. Categories from any other tag (e.g. `dev`) are hidden. |
@@ -0,0 +1,13 @@
-- Stores which admin marked a record or category invalid, and when.
-- Run once on the racetimer database before deploying the new WAR.
-- Restoring (making it valid again) clears these columns.
ALTER TABLE unloze_racetimer_css_2026.timer_records
ADD COLUMN invalidated_by_steam VARCHAR(32) NULL DEFAULT NULL AFTER is_invalid,
ADD COLUMN invalidated_by_name VARCHAR(128) CHARACTER SET utf8mb4 NULL DEFAULT NULL AFTER invalidated_by_steam,
ADD COLUMN invalidated_at TIMESTAMP NULL DEFAULT NULL AFTER invalidated_by_name;
ALTER TABLE unloze_racetimer_css_2026.zone_categories
ADD COLUMN invalidated_by_steam VARCHAR(32) NULL DEFAULT NULL AFTER is_invalid,
ADD COLUMN invalidated_by_name VARCHAR(128) CHARACTER SET utf8mb4 NULL DEFAULT NULL AFTER invalidated_by_steam,
ADD COLUMN invalidated_at TIMESTAMP NULL DEFAULT NULL AFTER invalidated_by_name;
@@ -140,6 +140,8 @@ public final class Dto {
public int points;
/** 1.0 = no bonus. Up to 4.0 for #1 on boards with 100+ completions. */
public double bonusMultiplier;
/** Points were raised so small Classic boards keep 1 point between the top 10 places (and at least 1). */
public boolean pointsRaised;
/** Unix seconds. Null for legacy records (date unknown). */
public Long recordedAt;
public boolean isLegacy;
@@ -189,6 +191,8 @@ public final class Dto {
public int completions;
public double bonusMultiplier;
public int points;
/** See BoardEntryDTO.pointsRaised. */
public boolean pointsRaised;
public Long recordedAt;
}
@@ -11,13 +11,21 @@ public final class BoardEntry {
public final int position;
public final int points;
public final double multiplier;
/** Points were raised by the Classic touch-ups (minimum 1, top-10 places 1 apart). */
public final boolean pointsRaised;
public BoardEntry(RecordRow record, Category category, int position, int points, double multiplier) {
this(record, category, position, points, multiplier, false);
}
public BoardEntry(RecordRow record, Category category, int position, int points, double multiplier,
boolean pointsRaised) {
this.record = record;
this.category = category;
this.position = position;
this.points = points;
this.multiplier = multiplier;
this.pointsRaised = pointsRaised;
}
/** An invalidated run shown on the board: no position, no points. */
@@ -22,7 +22,7 @@ public final class Category {
/** Who invalidated this category and when; null if valid or unknown. */
public Invalidation invalidation;
/** "Category N" exactly as the plugin numbers it in-game. */
/** "Category N" on the website. Same server_cvars on ZE1 and ZE2 share the number. */
public int number;
/** Valid best per player, fastest first. */
public List<BoardEntry> entries = new ArrayList<>();
@@ -108,6 +108,7 @@ public final class Views {
d.time = e.record.time;
d.points = e.points;
d.bonusMultiplier = e.multiplier;
d.pointsRaised = e.pointsRaised;
d.isLegacy = e.category.legacy;
d.recordedAt = e.category.legacy ? null : e.record.recordedAt;
d.isInvalid = e.isInvalid();
@@ -146,6 +147,7 @@ public final class Views {
d.completions = c.completions();
d.bonusMultiplier = e.multiplier;
d.points = e.points;
d.pointsRaised = e.pointsRaised;
d.recordedAt = c.legacy ? null : e.record.recordedAt;
d.isInvalid = e.isInvalid();
d.invalidation = invalidation(e.isInvalid(), e.record.invalidation);
@@ -15,11 +15,19 @@ package racetimer.service;
* 4x at #1 down to 2x at the top-1% mark, then 2x down to 1x at the
* top-5% mark, 1x after that.
* 4. CLASSIC RACETIMER (migrated) categories: final points divided by 10.
* 5. Whole-board touch-ups (boardPoints), which only ever raise points and in
* practice only change Classic boards, where dividing by 10 and rounding
* would squash places together:
* - everyone who gets points (step 1) gets at least 1;
* - each of the top 10 places gets at least 1 point more than the place
* below it (tied times still share their points).
*/
public final class Points {
public static final int HALF_CUTOFF_FROM = 200;
public static final int BONUS_FROM = 100;
/** Places 1..TOP_GAP_PLACES are always at least one point apart. */
public static final int TOP_GAP_PLACES = 10;
private Points() {
}
@@ -62,6 +70,45 @@ public final class Points {
}
/** The multiplier as shown to players, e.g. 3.2 or 1.65. */
/**
* Final points for a whole board. positions[i] is the position of the i-th
* entry, fastest first (ties repeat the same position). Applies step 5 on
* top of finalPoints. The result has the same length and order.
*/
public static int[] boardPoints(int[] positions, int n, boolean classic) {
int[] pts = new int[positions.length];
int pointed = pointedCount(n);
for (int i = 0; i < positions.length; i++) {
int p = finalPoints(positions[i], n, classic);
if (positions[i] >= 1 && positions[i] <= pointed && p < 1) {
p = 1;
}
pts[i] = p;
}
// Walk up from the bottom, one group of equal positions at a time.
int below = 0; // points of the next lower place that earns points (0 = none)
int i = positions.length - 1;
while (i >= 0) {
int j = i;
while (j > 0 && positions[j - 1] == positions[i]) {
j--;
}
int pos = positions[i];
if (pos >= 1 && pos <= pointed) {
int p = pts[i];
if (pos <= TOP_GAP_PLACES && p < below + 1) {
p = below + 1;
}
for (int k = j; k <= i; k++) {
pts[k] = p;
}
below = p;
}
i = j - 1;
}
return pts;
}
public static double displayMultiplier(int position, int n) {
return Math.round(multiplier(position, n) * 100.0) / 100.0;
}
@@ -68,6 +68,37 @@ public final class SnapshotBuilder {
}
};
/**
* Numbers the categories of one map+stage. Categories with exactly the same
* server_cvars share a number, so the same settings on ZE1 and ZE2 are both
* "Category 1" (told apart by their server tag). Distinct settings are
* numbered by their lowest cvars_hash. Hidden servers (dev) never take a
* number away from ze1/ze2: they reuse a matching number or come after.
*/
void numberByCvars(List<Category> list) {
Collections.sort(list, new Comparator<Category>() {
@Override
public int compare(Category a, Category b) {
int x = Boolean.compare(!a.ranked, !b.ranked); // public servers first
if (x != 0) {
return x;
}
x = Long.compare(a.cvarsHash, b.cvarsHash);
return x != 0 ? x : Integer.compare(a.id, b.id);
}
});
Map<String, Integer> numbers = new HashMap<>();
for (Category c : list) {
String key = c.serverCvars == null ? "" : c.serverCvars.trim();
Integer n = numbers.get(key);
if (n == null) {
n = numbers.size() + 1;
numbers.put(key, n);
}
c.number = n;
}
}
private final Set<String> rankedTags;
private final String classicServerCvars;
@@ -81,7 +112,7 @@ public final class SnapshotBuilder {
Collection<RecordRow> records,
Map<String, List<PlayerInfo.Badge>> badges) {
// --- Categories, numbered like the plugin: per map+stage, ordered by cvars_hash ---
// --- Categories, numbered per map+stage by their server settings ---
Map<Integer, Category> categories = new HashMap<>();
Map<String, List<Category>> byMapStage = new HashMap<>();
for (CategoryRow r : categoryRows) {
@@ -99,16 +130,7 @@ public final class SnapshotBuilder {
list.add(c);
}
for (List<Category> list : byMapStage.values()) {
Collections.sort(list, new Comparator<Category>() {
@Override
public int compare(Category a, Category b) {
int x = Long.compare(a.cvarsHash, b.cvarsHash);
return x != 0 ? x : Integer.compare(a.id, b.id);
}
});
for (int i = 0; i < list.size(); i++) {
list.get(i).number = i + 1;
}
numberByCvars(list);
}
// --- Players ---
@@ -188,6 +210,7 @@ public final class SnapshotBuilder {
Collections.sort(sorted, byTime);
int n = sorted.size();
List<BoardEntry> entries = new ArrayList<>(n);
int[] positions = new int[n];
int position = 0;
double previousTime = Double.NaN;
for (int i = 0; i < n; i++) {
@@ -196,9 +219,15 @@ public final class SnapshotBuilder {
position = i + 1; // tied times share the position of the first one
}
previousTime = r.time;
int points = c.givesPoints() ? Points.finalPoints(position, n, c.legacy) : 0;
double mult = c.givesPoints() ? Points.displayMultiplier(position, n) : 1.0;
BoardEntry e = new BoardEntry(r, c, position, points, mult);
positions[i] = position;
}
int[] boardPoints = c.givesPoints() ? Points.boardPoints(positions, n, c.legacy) : new int[n];
for (int i = 0; i < n; i++) {
RecordRow r = sorted.get(i);
int points = boardPoints[i];
boolean raised = c.givesPoints() && points > Points.finalPoints(positions[i], n, c.legacy);
double mult = c.givesPoints() ? Points.displayMultiplier(positions[i], n) : 1.0;
BoardEntry e = new BoardEntry(r, c, positions[i], points, mult, raised);
entries.add(e);
PlayerInfo p = players.get(r.steamAuth);
p.bests.add(e);
@@ -292,7 +321,11 @@ public final class SnapshotBuilder {
@Override
public int compare(Category a, Category b) {
int x = Integer.compare(a.stage, b.stage);
return x != 0 ? x : Integer.compare(a.number, b.number);
if (x != 0) {
return x;
}
x = Integer.compare(a.number, b.number);
return x != 0 ? x : a.serverTag.compareTo(b.serverTag); // same settings: ze1 before ze2
}
});
for (Category c : publicCategories) {
@@ -102,8 +102,8 @@ public class ApiTest {
ContainerResponse r = call("GET", "timers/player/" + A, null, null);
assertEquals(200, r.getStatus());
JsonObject p = json(r).getAsJsonObject();
// CLASSIC board with 2 finishers: A is #1 -> 2 points / 10 = 0 (rounded)
assertEquals(0, p.get("PlayerPoints").getAsInt());
// CLASSIC board with 2 finishers: 2/10 would round to 0, but #2 gets at least 1 and #1 one more
assertEquals(2, p.get("PlayerPoints").getAsInt());
assertEquals("ze1", p.get("server").getAsString());
assertEquals("76561198029832363", p.get("steamID64").getAsString());
assertEquals(1, p.getAsJsonObject("servers").getAsJsonObject("ze2").get("rank").getAsInt());
@@ -1,5 +1,6 @@
package racetimer.service;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
@@ -72,6 +73,54 @@ public class PointsTest {
}
}
private static int[] seq(int n) {
int[] p = new int[n];
for (int i = 0; i < n; i++) {
p[i] = i + 1;
}
return p;
}
@Test
public void smallClassicBoardKeepsOnePointBetweenPlaces() {
// The real 9-record Classic board: was 1,1,1,1,1,0,0,0,0.
assertArrayEquals(new int[] {9, 8, 7, 6, 5, 4, 3, 2, 1}, Points.boardPoints(seq(9), 9, true));
}
@Test
public void onlyTopTenGetTheGapButEveryoneWithPointsGetsAtLeastOne() {
int[] p = Points.boardPoints(seq(30), 30, true);
// Places 11..30: rounded n/10 values, at least 1.
assertEquals(2, p[10]);
assertEquals(1, p[29]);
for (int i = 0; i < 10; i++) {
assertEquals("place " + (i + 1), p[i + 1] + 1 <= p[i], true);
}
assertEquals(12, p[0]);
}
@Test
public void tiesShareRaisedPoints() {
int[] p = Points.boardPoints(new int[] {1, 2, 2, 4}, 4, true);
assertArrayEquals(new int[] {3, 2, 2, 1}, p);
}
@Test
public void bigBoardsAndNewRecordsAreUnchanged() {
for (int n : new int[] {5, 150, 1000, 1470}) {
int[] pos = seq(n);
int[] newPts = Points.boardPoints(pos, n, false);
for (int i = 0; i < n; i++) {
assertEquals(Points.finalPoints(i + 1, n, false), newPts[i]);
}
}
int[] classic = Points.boardPoints(seq(1470), 1470, true);
for (int i = 0; i < 735; i++) {
assertEquals(Points.finalPoints(i + 1, 1470, true), classic[i]);
}
assertEquals(0, classic[735]); // past the 50% cut still 0
}
@Test
public void emptyBoard() {
assertEquals(0, Points.pointedCount(0));
@@ -5,6 +5,7 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.List;
import org.junit.Test;
import racetimer.TestData;
import racetimer.model.BoardEntry;
@@ -18,6 +19,27 @@ public class SnapshotBuilderTest {
private static final String B = TestData.steam(2);
private static final String C = TestData.steam(3);
@Test
public void sameCvarsOnZe1AndZe2ShareACategoryNumber() {
// The real ze_pirates_port_royal_v3_6 stage 1 rows.
String a = "sv_gravity 800, sv_enablebunnyhopping 1, sv_autobunnyhopping 0, sv_accelerate 5, sv_airaccelerate 10, "
+ "tickrate 100, sv_friction 4, client_speed 1.00, client_gravity 1.00";
String b = a.replace("sv_enablebunnyhopping 1", "sv_enablebunnyhopping 0");
TestData d = new TestData()
.category(87, "ze_p", 1, "ze1", TestData.CLASSIC, -706992435L, false)
.category(1963, "ze_p", 1, "ze1", a, -1995318401L, false)
.category(4412, "ze_p", 1, "ze2", a, -1978540782L, false)
.category(4564, "ze_p", 1, "ze1", b, 113025096L, false);
Snapshot s = d.build();
assertEquals(1, s.categories.get(1963).number);
assertEquals(1, s.categories.get(4412).number); // same settings, other server
assertEquals(2, s.categories.get(87).number);
assertEquals(3, s.categories.get(4564).number);
List<Category> shown = s.mapsByName.get("ze_p").stages.get(1);
assertEquals("ze1", shown.get(0).serverTag); // Category 1 ZE1, then Category 1 ZE2
assertEquals("ze2", shown.get(1).serverTag);
}
@Test
public void categoriesAreNumberedByCvarsHashAcrossServers() {
TestData d = new TestData()
@@ -29,8 +51,8 @@ public class SnapshotBuilderTest {
Snapshot s = d.build();
assertEquals(1, s.categories.get(12).number); // most negative hash first
assertEquals(2, s.categories.get(10).number);
assertEquals(3, s.categories.get(14).number); // dev still takes a number, like in-game
assertEquals(4, s.categories.get(11).number);
assertEquals(3, s.categories.get(11).number);
assertEquals(4, s.categories.get(14).number); // hidden dev server does not take a public number
assertEquals(1, s.categories.get(13).number);
// dev is hidden from the public map list
assertEquals(3, s.mapsByName.get("ze_map").stages.get(1).size());
@@ -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()],
});
Binary file not shown.