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 ; if (player.isError) { return isNotFound(player.error) ? ( ) : ( 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 ( <>
{tab === "records" ? ( setTab("history", id)} /> ) : ( setTab("history", id)} /> )} ); } function ProfileHeader({ p }: { p: Player }) { const { server } = useServer(); return (

{p.name}

{p.steamID} {p.steamID64 && ( Steam profile ↗ )}
{p.badges.length > 0 && (
{p.badges.map((b) => ( {b.name} ))}
)}
{SERVERS.map((s) => { const st = p.servers[s]; return (
{serverLabel(s)} points
{formatNumber(st.points)}
{st.times > 0 ? ( <> Rank #{formatNumber(st.rank)} · {formatNumber(st.times)} records ) : ( "No records yet" )}
); })}
); } 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 ; if (q.isError) return q.refetch()} />; if (rows.length === 0) return This player has no records yet.; return ( <>
{(["all", "ze1", "ze2"] as const).map((s) => ( ))}
setText(e.target.value)} aria-label="Filter maps" />
{shown.map((r) => ( ))}
Map Time Place Bonus Points Set
{r.mapName}
Stage {r.stage} · {r.isInvalid && } {r.categoryInvalid && }
{r.isInvalid && } {r.categoryInvalid && ( )}
{formatTime(r.time)} {r.isInvalid ? ( – ) : ( <> #{formatNumber(r.position)} / {formatNumber(r.completions)} )} {!r.isInvalid && ( )}
{!r.isLegacy && ( )}
{shown.length === 0 && No records match the filter.} 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 ( <>

Every saved run was a new personal best when it was set. Classic (migrated) records have no date and are listed last.

{q.isPending && } {q.isError && q.refetch()} />} {q.isSuccess && entries.length === 0 && No improvements to show.} {categoryId && entries.length > 0 && } {entries.length > 0 && (
    {entries.map((e) => (
  1. {formatTime(e.time)} {e.improvedBy !== null && e.improvedBy > 0 && ( {" "} improved by {formatTime(e.improvedBy)}s )} {e.previousTime === null && !e.isInvalid && first time} {e.isCurrentBest && Current best} {e.isInvalid && }
    {!categoryId ? ( ) : ( <> {e.mapName} · Stage {e.stage} · Category {e.categoryNumber} )}{" "} · {serverLabel(e.serverTag)} {e.previousTime !== null && <> · previous best {formatTime(e.previousTime)}}
    {e.isInvalid && } {e.categoryInvalid && ( )}
  2. ))}
)} q.fetchNextPage()} /> ); }