Files
ForbiddenStarsApp/frontend/src/pages/OverallStatsPage.tsx
T
NotBigGhostandClaude Opus 5 adc47beb72 Рейтинг: топ, профиль и справка под новую шкалу
Общий топ: столбец «Поб» и сортировка по победам убраны, чтобы четырёхзначный
рейтинг помещался в строку; «Очки» → «Рейтинг» (и в карточке профиля).
Справка переписана под Elo: шкала 1500/400, ожидание и формула ΔR, K новичка,
вес стола, множитель отрыва и близость по типу победы, необязательность
раунда, целей и миров, «последний выживший», лимит 8/9 раундов, примеры
из документа, общий и групповой рейтинг, лучшая партия по приросту.
В документе рейтинга статус — утверждено и реализовано. #23

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-14 22:47:25 +03:00

95 lines
3.7 KiB
TypeScript

import { useMemo, useState } from "react";
import { Avatar } from "../components/Avatar";
import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner";
import type { LeaderboardEntry } from "../domain/types";
import { useLeaderboard } from "../hooks/stats";
// Столбца побед нет: четырёхзначный рейтинг иначе не помещается в строку, а доля побед
// уже есть в WR.
type SortKey = "rank" | "games" | "win_rate" | "score";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
}
export function OverallStatsPage() {
const { data, isLoading, isError } = useLeaderboard(200);
const [q, setQ] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("rank");
const [dir, setDir] = useState<1 | -1>(1); // 1 — по возрастанию
const toggleSort = (k: SortKey) => {
if (k === sortKey) setDir((d) => (d === 1 ? -1 : 1));
else {
setSortKey(k);
setDir(k === "rank" ? 1 : -1); // для метрик по умолчанию «лучшие сверху»
}
};
const filterSort = useMemo(() => {
const needle = q.trim().toLowerCase();
const num = (e: LeaderboardEntry) => Number(e[sortKey] ?? 0);
return (list: LeaderboardEntry[]) =>
list
.filter((e) => e.nickname.toLowerCase().includes(needle))
.sort((a, b) => (num(a) - num(b)) * dir);
}, [q, sortKey, dir]);
if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Не удалось загрузить статистику.</div>;
const entries = filterSort(data.entries);
const provisional = filterSort(data.provisional);
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
const row = (e: LeaderboardEntry, isProvisional = false) => (
<PlayerLink key={e.user_id} userId={e.user_id} className="st-row">
<span className={"st-num" + (e.rank && e.rank <= 3 ? " gold" : "")}>{e.rank ?? "—"}</span>
<span className="st-player">
<Avatar url={e.avatar_url} nickname={e.nickname} size={28} />
<span className="nick">{e.nickname}</span>
</span>
<span className="st-num">{e.games}</span>
<span className="st-num">{pct(e.win_rate)}</span>
<span className={"st-num lb-score" + (isProvisional || e.score == null ? " provisional" : "")}>
{e.score ?? "—"}
</span>
</PlayerLink>
);
return (
<div>
{/* Название страницы уже в верхней панели — в теле сразу поиск. */}
<div className="card">
<input placeholder="Поиск по нику…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="card">
<div className="st-head">
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
<span>Игрок</span>
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
<button onClick={() => toggleSort("score")}>Рейтинг{arrow("score")}</button>
</div>
{entries.length === 0 ? (
<div className="muted small" style={{ paddingTop: 8 }}>
Никого не найдено.
</div>
) : (
entries.map((e) => row(e))
)}
</div>
{provisional.length > 0 && (
<div className="card">
<div className="label">Новички (меньше {data.min_games} игр)</div>
{provisional.map((e) => row(e, true))}
</div>
)}
</div>
);
}