Improve 1vs1 ranked leaderboard (#3270)

## Description:

The two tables look much more similar now
And you can see the player names now

Before:


https://github.com/user-attachments/assets/59f94e1a-5909-4d13-8ff3-bd36775f4ae6

After:


https://github.com/user-attachments/assets/51234d14-20c2-4b14-a7cc-ceef7cf9a8fd

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
This commit is contained in:
FloPinguin
2026-02-22 19:52:59 +01:00
committed by GitHub
parent 9af4ff806c
commit edc3e20a9f
3 changed files with 286 additions and 259 deletions
@@ -195,189 +195,208 @@ export class LeaderboardClanTable extends LitElement {
const maxGames = Math.max(...clans.map((c) => c.games), 1);
return html`
<div class="h-full px-6 pb-6">
<div
class="h-full overflow-y-auto overflow-x-auto rounded-xl border border-white/5 bg-black/20"
>
<table class="w-full text-sm border-collapse">
<thead>
<tr
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-white/2"
>
<th class="py-4 px-4 text-center font-bold w-16">
${translateText("leaderboard_modal.rank")}
</th>
<th class="py-4 px-4 text-left font-bold">
${translateText("leaderboard_modal.clan")}
</th>
<th
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors"
<div class="h-full">
<div class="h-full border border-white/5 bg-black/20">
<div
class="h-full overflow-y-auto overflow-x-auto scrollbar-thin scrollbar-thumb-white/20"
>
<table class="w-full text-sm border-collapse table-fixed">
<colgroup>
<col style="width: 4rem" />
<col />
<col style="width: 8rem" />
<col style="width: 6rem" />
<col style="width: 6rem" />
<col style="width: 6rem" />
</colgroup>
<thead class="sticky top-0 z-10">
<tr
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-[#1e2433]"
>
<button
@click=${() => this.handleSort("games")}
aria-sort=${this.sortBy === "games"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
<th class="py-4 px-4 text-center font-bold">
${translateText("leaderboard_modal.rank")}
</th>
<th class="py-4 px-4 text-left font-bold">
${translateText("leaderboard_modal.clan")}
</th>
<th
class="py-4 px-4 text-right font-bold cursor-pointer hover:text-white/60 transition-colors"
>
${translateText("leaderboard_modal.games")}
${this.sortBy === "games"
? this.sortOrder === "asc"
? "↑"
: ""
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
title=${translateText("leaderboard_modal.win_score_tooltip")}
>
<button
@click=${() => this.handleSort("winScore")}
aria-sort=${this.sortBy === "winScore"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
<button
class="whitespace-nowrap uppercase"
@click=${() => this.handleSort("games")}
aria-sort=${this.sortBy === "games"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.games")}
${this.sortBy === "games"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold cursor-pointer hover:text-white/60 transition-colors"
title=${translateText(
"leaderboard_modal.win_score_tooltip",
)}
>
${translateText("leaderboard_modal.win_score")}
${this.sortBy === "winScore"
? this.sortOrder === "asc"
? "↑"
: ""
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
title=${translateText("leaderboard_modal.loss_score_tooltip")}
>
<button
@click=${() => this.handleSort("lossScore")}
aria-sort=${this.sortBy === "lossScore"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
<button
class="whitespace-nowrap uppercase"
@click=${() => this.handleSort("winScore")}
aria-sort=${this.sortBy === "winScore"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.win_score")}
${this.sortBy === "winScore"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold cursor-pointer hover:text-white/60 transition-colors"
title=${translateText(
"leaderboard_modal.loss_score_tooltip",
)}
>
${translateText("leaderboard_modal.loss_score")}
${this.sortBy === "lossScore"
? this.sortOrder === "asc"
? "↑"
: ""
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors"
>
<button
@click=${() => this.handleSort("ratio")}
aria-sort=${this.sortBy === "ratio"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
<button
class="whitespace-nowrap uppercase"
@click=${() => this.handleSort("lossScore")}
aria-sort=${this.sortBy === "lossScore"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.loss_score")}
${this.sortBy === "lossScore"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors"
>
${translateText("leaderboard_modal.win_loss_ratio")}
${this.sortBy === "ratio"
? this.sortOrder === "asc"
? "↑"
: ""
: "↕"}
</button>
</th>
</tr>
</thead>
<tbody>
${sorted.map((clan, index) => {
const displayRank = index + 1;
const rankColor =
displayRank === 1
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
: displayRank === 2
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
: displayRank === 3
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
: "text-white/40 bg-white/5";
const rankIcon =
displayRank === 1
? "👑"
: displayRank === 2
? "🥈"
: displayRank === 3
? "🥉"
: String(displayRank);
<button
class="whitespace-nowrap uppercase"
@click=${() => this.handleSort("ratio")}
aria-sort=${this.sortBy === "ratio"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.win_loss_ratio")}
${this.sortBy === "ratio"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
</tr>
</thead>
<tbody>
${sorted.map((clan, index) => {
const displayRank = index + 1;
const rankColor =
displayRank === 1
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
: displayRank === 2
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
: displayRank === 3
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
: "text-white/40 bg-white/5";
const rankIcon =
displayRank === 1
? "👑"
: displayRank === 2
? "🥈"
: displayRank === 3
? "🥉"
: String(displayRank);
return html`
<tr
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
>
<td class="py-3 px-4 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
${rankIcon}
</div>
</td>
<td class="py-3 px-4 font-bold text-blue-300">
<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 inline-block"
>
${clan.clanTag}
</div>
</td>
<td class="py-3 px-4 text-right">
<div class="flex flex-col items-end gap-1">
<span class="text-white font-mono font-medium"
>${clan.games.toLocaleString()}</span
>
return html`
<tr
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
>
<td class="py-3 px-4 text-center">
<div
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
<div
class="h-full bg-blue-500/50 rounded-full"
style="width: ${(clan.games / maxGames) * 100}%"
></div>
${rankIcon}
</div>
</div>
</td>
<td
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
>
${clan.weightedWins.toLocaleString("fullwide", {
maximumFractionDigits: 1,
})}
</td>
<td
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
>
${clan.weightedLosses.toLocaleString("fullwide", {
maximumFractionDigits: 1,
})}
</td>
<td class="py-3 px-4 text-right pr-6">
<div class="inline-flex flex-col items-end">
<span
class="font-mono font-bold ${clan.weightedWLRatio >= 1
? "text-green-400"
: "text-red-400"}"
>${clan.weightedWLRatio.toLocaleString("fullwide", {
maximumFractionDigits: 2,
})}</span
</td>
<td class="py-3 px-4 font-bold text-blue-300">
<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 inline-block"
>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("leaderboard_modal.ratio")}</span
>
</div>
</td>
</tr>
`;
})}
</tbody>
</table>
${clan.clanTag}
</div>
</td>
<td class="py-3 px-4 text-right">
<div class="flex flex-col items-end gap-1">
<span class="text-white font-mono font-medium"
>${clan.games.toLocaleString()}</span
>
<div
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500/50 rounded-full"
style="width: ${(clan.games / maxGames) * 100}%"
></div>
</div>
</div>
</td>
<td
class="py-3 px-4 text-right font-mono text-green-400/90"
>
${clan.weightedWins.toLocaleString(undefined, {
maximumFractionDigits: 1,
})}
</td>
<td
class="py-3 px-4 text-right font-mono text-red-400/90"
>
${clan.weightedLosses.toLocaleString(undefined, {
maximumFractionDigits: 1,
})}
</td>
<td class="py-3 px-4 text-right pr-6">
<div class="inline-flex flex-col items-end">
<span
class="font-mono font-bold ${clan.weightedWLRatio >=
1
? "text-green-400"
: "text-red-400"}"
>${clan.weightedWLRatio.toLocaleString(undefined, {
maximumFractionDigits: 2,
})}</span
>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("leaderboard_modal.ratio")}</span
>
</div>
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
</div>
</div>
`;
@@ -1,5 +1,4 @@
import { virtualize } from "@lit-labs/virtualizer/virtualize.js";
import { html, LitElement } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas";
import { RankedType } from "../../../core/game/Game";
@@ -23,7 +22,7 @@ export class LeaderboardPlayerList extends LitElement {
private currentUserId: string | null = null;
private currentUserIdLoaded = false;
@query(".virtualizer-container") private virtualizerContainer?: HTMLElement;
@query(".scroll-container") private scrollContainer?: HTMLElement;
createRenderRoot() {
return this;
@@ -152,12 +151,12 @@ export class LeaderboardPlayerList extends LitElement {
return;
}
if (!this.virtualizerContainer || !this.isVisible()) {
if (!this.scrollContainer || !this.isVisible()) {
this.showStickyUser = false;
return;
}
const currentRow = this.virtualizerContainer.querySelector(
const currentRow = this.scrollContainer.querySelector(
'[data-current-user="true"]',
) as HTMLElement | null;
@@ -166,7 +165,7 @@ export class LeaderboardPlayerList extends LitElement {
return;
}
const containerRect = this.virtualizerContainer.getBoundingClientRect();
const containerRect = this.scrollContainer.getBoundingClientRect();
const rowRect = currentRow.getBoundingClientRect();
const isVisible =
rowRect.top >= containerRect.top &&
@@ -188,12 +187,12 @@ export class LeaderboardPlayerList extends LitElement {
private maybeLoadMorePlayers() {
if (this.isLoading || this.isLoadingMore) return;
if (!this.playerHasMore || this.error || this.loadMoreError) return;
if (!this.virtualizerContainer || !this.isVisible()) return;
if (!this.scrollContainer || !this.isVisible()) return;
const threshold = 64 * 3;
const scrollTop = this.virtualizerContainer.scrollTop;
const containerHeight = this.virtualizerContainer.clientHeight;
const scrollHeight = this.virtualizerContainer.scrollHeight;
const scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight;
const scrollHeight = this.scrollContainer.scrollHeight;
const nearBottom = scrollTop + containerHeight >= scrollHeight - threshold;
if (containerHeight === 0 || scrollHeight === 0) return; // guard
@@ -211,7 +210,6 @@ export class LeaderboardPlayerList extends LitElement {
private renderPlayerRow(player: PlayerLeaderboardEntry) {
const isCurrentUser = this.currentUserEntry?.playerId === player.playerId;
const displayRank = player.rank;
const winRate = player.games > 0 ? player.wins / player.games : 0;
const rankColor =
{
@@ -228,60 +226,56 @@ export class LeaderboardPlayerList extends LitElement {
}?.[displayRank] ?? String(displayRank);
return html`
<div
<tr
data-current-user=${isCurrentUser ? "true" : "false"}
class="flex items-center border-b border-white/5 py-3 px-6 hover:bg-white/[0.07] transition-colors w-full ${isCurrentUser
? "bg-blue-500/15 border-l-4 border-l-blue-500 pl-5"
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group ${isCurrentUser
? "bg-blue-500/15"
: ""}"
>
<div class="w-16 shrink-0 text-center">
<td class="py-3 px-4 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
${rankIcon}
</div>
</div>
<div class="flex-1 flex items-center gap-3 overflow-hidden ml-4">
<span class="font-bold text-blue-300 truncate text-base"
>${player.username}</span
>
${player.clanTag
? html`<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-[10px] font-bold text-blue-300 shrink-0"
>
${player.clanTag}
</div>`
: ""}
</div>
<div class="flex flex-col items-end gap-1 w-32">
<div class="text-right font-mono text-white font-medium">
${player.elo}
<span class="text-[10px] text-white/30 truncate"
>${translateText("leaderboard_modal.elo")}</span
</td>
<td class="py-3 px-4">
<div class="flex items-center gap-2">
${player.clanTag
? html`<div
class="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-[10px] font-bold text-blue-300 shrink-0"
>
${player.clanTag}
</div>`
: ""}
<span class="font-bold text-blue-300 truncate text-base"
>${player.clanTag
? player.username.replace(/^\[.*?\]\s*/, "")
: player.username}</span
>
</div>
</div>
<div class="flex-col items-end gap-1 w-32 hidden md:flex">
<div class="text-right font-mono text-white font-medium">
${player.games}
<span class="text-[10px] text-white/30 uppercase"
>${translateText("leaderboard_modal.games")}</span
</td>
<td class="py-3 px-4 text-right">
<span class="font-mono text-white font-medium">${player.elo}</span>
</td>
<td class="py-3 px-4 text-right">
<span class="font-mono text-white font-medium">${player.games}</span>
</td>
<td class="py-3 px-4 text-right pr-6">
<div class="inline-flex flex-col items-end">
<span
class="font-mono font-bold ${player.winRate >= 0.5
? "text-green-400"
: "text-red-400"}"
>${(player.winRate * 100).toFixed(1)}%</span
>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("leaderboard_modal.ratio")}</span
>
</div>
</div>
<div class="inline-flex flex-col items-end pr-6 w-32">
<span
class="font-mono font-bold ${winRate >= 0.5
? "text-green-400"
: "text-red-400"}"
>${(winRate * 100).toFixed(1)}%</span
>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("leaderboard_modal.ratio")}</span
>
</div>
</div>
</td>
</tr>
`;
}
@@ -372,52 +366,61 @@ export class LeaderboardPlayerList extends LitElement {
if (this.error) return this.renderError();
return html`
<div class="flex flex-col h-full overflow-hidden">
<div
class="flex items-center text-[10px] uppercase tracking-wider text-white/40 font-bold px-6 py-4 border-b border-white/5 bg-white/2"
>
<div class="w-16 text-center">
${translateText("leaderboard_modal.rank")}
</div>
<div class="flex-1 ml-4">
${translateText("leaderboard_modal.player")}
</div>
<div class="w-32 text-right">
${translateText("leaderboard_modal.elo")}
</div>
<div class="w-32 text-right hidden md:block">
${translateText("leaderboard_modal.games")}
</div>
<div class="w-32 text-right pr-6">
${translateText("leaderboard_modal.win_loss_ratio")}
</div>
</div>
<div class="relative flex-1 min-h-0">
<div class="h-full">
<div class="h-full border border-white/5 bg-black/20 relative">
<div
class="virtualizer-container h-full overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 ${this
class="scroll-container h-full overflow-y-auto overflow-x-auto scrollbar-thin scrollbar-thumb-white/20 ${this
.showStickyUser
? "pb-20"
: "pb-0"}"
@scroll=${() => this.handleScroll()}
>
${virtualize({
items: this.playerData,
renderItem: (player) => this.renderPlayerRow(player),
scroller: true,
})}
<table class="w-full text-sm border-collapse table-fixed">
<colgroup>
<col style="width: 4rem" />
<col />
<col style="width: 6rem" />
<col style="width: 6rem" />
<col style="width: 6rem" />
</colgroup>
<thead class="sticky top-0 z-10">
<tr
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-[#1e2433]"
>
<th class="py-4 px-4 text-center font-bold">
${translateText("leaderboard_modal.rank")}
</th>
<th class="py-4 px-4 text-left font-bold">
${translateText("leaderboard_modal.player")}
</th>
<th class="py-4 px-4 text-right font-bold">
${translateText("leaderboard_modal.elo")}
</th>
<th class="py-4 px-4 text-right font-bold">
${translateText("leaderboard_modal.games")}
</th>
<th class="py-4 px-4 text-right font-bold pr-6">
${translateText("leaderboard_modal.win_loss_ratio")}
</th>
</tr>
</thead>
<tbody>
${this.playerData.map((player) => this.renderPlayerRow(player))}
</tbody>
</table>
${this.renderPlayerFooter()}
</div>
${this.currentUserEntry
? html`
<div class="absolute inset-x-0 bottom-0">
<div class="absolute inset-x-0 bottom-0 z-20">
<div
class="bg-blue-600/90 backdrop-blur-md border-t border-blue-400/30 py-4 px-6 shadow-2xl flex items-center transition-all duration-200 ${this
.showStickyUser
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-3 pointer-events-none"}"
aria-hidden=${this.showStickyUser ? "false" : "true"}
aria-hidden=${this.showStickyUser ? nothing : "true"}
>
<div class="w-16 text-center">
<div class="w-10 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg bg-white/20 text-white"
>
@@ -432,10 +435,15 @@ export class LeaderboardPlayerList extends LitElement {
)}</span
>
<span class="font-bold text-white text-base"
>${this.currentUserEntry.username}</span
>${this.currentUserEntry.clanTag
? this.currentUserEntry.username.replace(
/^\[.*?\]\s*/,
"",
)
: this.currentUserEntry.username}</span
>
</div>
<div class="flex flex-col items-end w-32">
<div class="flex flex-col items-end w-20">
<div class="font-mono text-white font-bold text-lg">
${this.currentUserEntry.elo}
<span class="text-[10px] text-white/60"
@@ -47,7 +47,7 @@ export class LeaderboardTabs extends LitElement {
return html`
<div
role="tablist"
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-6 w-fit mx-auto mt-4"
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-4 w-fit mx-auto mt-4"
>
<button
type="button"