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,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} />}
</>
);
}