mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-22 22:58:06 +00:00
add game history to a profile (#4662)
## Description: adds game tab to player profile: <img width="1235" height="928" alt="image" src="https://github.com/user-attachments/assets/660fba48-18d0-4538-a9d5-f33abc0e693d" /> also make ui a bit more uniform, both account + profile modal match, and changed the filtering options to be the same as the games tab <img width="972" height="581" alt="image" src="https://github.com/user-attachments/assets/6f10f78e-e32b-42a6-8df4-7aa6f0cf5146" /> <img width="1006" height="623" alt="image" src="https://github.com/user-attachments/assets/caffb742-1d64-4d21-aa13-d3a7191088ca" /> ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n
This commit is contained in:
@@ -38,15 +38,12 @@
|
||||
"marketing_title": "Email updates",
|
||||
"no_games": "No games played yet.",
|
||||
"no_stats": "No stats available yet. Play some games to start tracking.",
|
||||
"not_found": "Not Found",
|
||||
"or": "OR",
|
||||
"public_player_id": "Public ID:",
|
||||
"reactivate_subscription": "Reactivate",
|
||||
"recovery_email_sent": "Recovery email sent to {email}",
|
||||
"reward_daily": "Daily subscription reward",
|
||||
"reward_signup_bonus": "Subscription signup bonus",
|
||||
"sign_in_desc": "Sign in to save your stats and progress",
|
||||
"stats_overview": "Stats Overview",
|
||||
"sub_price_monthly": "{price}/mo",
|
||||
"sub_renews_on": "Renews {date}",
|
||||
"sub_status_active": "Active",
|
||||
|
||||
+10
-36
@@ -107,25 +107,19 @@ export class AccountModal extends BaseModal {
|
||||
protected renderHeaderSlot() {
|
||||
const isLoggedIn = !!this.userMeResponse?.user;
|
||||
const publicId = this.userMeResponse?.player?.publicId ?? "";
|
||||
const displayId = publicId || translateText("account_modal.not_found");
|
||||
return modalHeader({
|
||||
title: translateText("account_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent:
|
||||
isLoggedIn && !this.isLoadingUser
|
||||
isLoggedIn && !this.isLoadingUser && publicId
|
||||
? html`
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
|
||||
>${translateText("account_modal.public_player_id")}</span
|
||||
>
|
||||
<copy-button
|
||||
.lobbyId=${publicId}
|
||||
.copyText=${publicId}
|
||||
.displayText=${displayId}
|
||||
></copy-button>
|
||||
</div>
|
||||
<copy-button
|
||||
class="shrink-0"
|
||||
.copyText=${playerProfileUrl(publicId)}
|
||||
.displayText=${translateText("player_profile.share")}
|
||||
.showVisibilityToggle=${false}
|
||||
></copy-button>
|
||||
`
|
||||
: undefined,
|
||||
});
|
||||
@@ -412,30 +406,10 @@ export class AccountModal extends BaseModal {
|
||||
translateText("account_modal.no_stats"),
|
||||
);
|
||||
}
|
||||
const publicId = this.userMeResponse?.player?.publicId ?? "";
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<div class="flex items-center justify-between gap-2 mb-4">
|
||||
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span class="text-blue-400">📊</span>
|
||||
${translateText("account_modal.stats_overview")}
|
||||
</h3>
|
||||
${publicId
|
||||
? html`
|
||||
<copy-button
|
||||
compact
|
||||
class="shrink-0"
|
||||
.copyText=${playerProfileUrl(publicId)}
|
||||
.displayText=${translateText("player_profile.share")}
|
||||
.showVisibilityToggle=${false}
|
||||
></copy-button>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
</div>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export class GameStatsModal extends BaseModal {
|
||||
protected routerName = "stats";
|
||||
|
||||
@state() private gameId: string | null = null;
|
||||
private openedFrom: "account" | "clan" | null = null;
|
||||
private openedFrom: "account" | "clan" | "profile" | null = null;
|
||||
|
||||
protected modalConfig() {
|
||||
return { maxWidth: "960px" };
|
||||
@@ -66,6 +66,11 @@ export class GameStatsModal extends BaseModal {
|
||||
this.open({ gameID: gameId });
|
||||
}
|
||||
|
||||
public openFromProfile(gameId: string): void {
|
||||
this.openedFrom = "profile";
|
||||
this.open({ gameID: gameId });
|
||||
}
|
||||
|
||||
private back(): void {
|
||||
const openedFrom = this.openedFrom;
|
||||
this.close();
|
||||
@@ -73,6 +78,12 @@ export class GameStatsModal extends BaseModal {
|
||||
document
|
||||
.querySelector<HTMLElement & { returnToGames(): void }>("account-modal")
|
||||
?.returnToGames();
|
||||
} else if (openedFrom === "profile") {
|
||||
document
|
||||
.querySelector<
|
||||
HTMLElement & { returnToGames(): void }
|
||||
>("player-profile-modal")
|
||||
?.returnToGames();
|
||||
} else if (openedFrom === "clan") {
|
||||
document
|
||||
.querySelector<
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { ClientEnv } from "src/client/ClientEnv";
|
||||
import { isVerifiedUsername, type PlayerStatsTree } from "../core/ApiSchemas";
|
||||
import { fetchPublicPlayerProfile } from "./Api";
|
||||
import "./components/baseComponents/stats/PlayerGameHistoryView";
|
||||
import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView";
|
||||
import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/PlayerName";
|
||||
@@ -23,9 +26,23 @@ export class PlayerProfileModal extends BaseModal {
|
||||
@state() private statsTree: PlayerStatsTree | null = null;
|
||||
@state() private loading = false;
|
||||
private openedFrom: "clan" | "leaderboard" | "account" | null = null;
|
||||
// Mirrors the account modal's Games tab: keep the accumulated history list +
|
||||
// cursor across tab switches (and the game-stats detour) so re-entering Games
|
||||
// restores the scroll position the viewer had built up.
|
||||
private gameHistoryCache: PlayerGameHistoryCache | null = null;
|
||||
private gamesScrollTop = 0;
|
||||
private restoreGamesScrollAfterOpen = false;
|
||||
// Bumped on every profile load so a superseded in-flight response is dropped.
|
||||
private loadGeneration = 0;
|
||||
|
||||
protected modalConfig() {
|
||||
return { maxWidth: "960px" };
|
||||
return {
|
||||
maxWidth: "960px",
|
||||
tabs: [
|
||||
{ key: "stats", label: translateText("account_modal.tab_stats") },
|
||||
{ key: "games", label: translateText("account_modal.tab_games") },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
protected renderHeaderSlot() {
|
||||
@@ -58,14 +75,45 @@ export class PlayerProfileModal extends BaseModal {
|
||||
});
|
||||
}
|
||||
|
||||
protected renderBody() {
|
||||
protected renderBody(tab: string) {
|
||||
return html`
|
||||
<div class="px-3 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-7">
|
||||
${this.renderProfile()}
|
||||
<div class="custom-scrollbar mr-1">
|
||||
<div class="p-6">
|
||||
${tab === "games" ? this.renderGames() : this.renderProfile()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGames() {
|
||||
const publicId = this.publicId;
|
||||
if (!publicId) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<span class="text-4xl mb-4">🎮</span>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("account_modal.no_games")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<player-game-history-view
|
||||
.publicId=${publicId}
|
||||
.cachedState=${this.gameHistoryCache?.publicId === publicId
|
||||
? this.gameHistoryCache
|
||||
: null}
|
||||
@history-updated=${(e: CustomEvent<PlayerGameHistoryCache>) => {
|
||||
this.gameHistoryCache = e.detail;
|
||||
}}
|
||||
@view-stats=${(e: CustomEvent<{ gameId: string }>) =>
|
||||
this.openGameStats(e.detail.gameId)}
|
||||
@view-game=${(e: CustomEvent<{ gameId: string }>) =>
|
||||
this.viewGame(e.detail.gameId)}
|
||||
></player-game-history-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderProfile() {
|
||||
if (this.loading) {
|
||||
return this.renderLoadingSpinner(translateText("player_profile.loading"));
|
||||
@@ -81,11 +129,9 @@ export class PlayerProfileModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
</div>
|
||||
<player-stats-tree-view
|
||||
.statsTree=${this.statsTree}
|
||||
></player-stats-tree-view>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -94,9 +140,33 @@ export class PlayerProfileModal extends BaseModal {
|
||||
typeof args?.publicID === "string" && args.publicID.length > 0
|
||||
? args.publicID
|
||||
: null;
|
||||
|
||||
// Returning from the game-stats modal. The page router closed this modal
|
||||
// underneath when the stats page showed, but onClose deliberately preserves
|
||||
// state (like the account modal) — so restore the scroll and keep the
|
||||
// game-history cache instead of refetching. Preserve even when the profile
|
||||
// stats never loaded (still pending / failed): the Games tab loads
|
||||
// independently, so its list should survive the detour regardless.
|
||||
if (
|
||||
this.restoreGamesScrollAfterOpen &&
|
||||
publicId !== null &&
|
||||
publicId === this.publicId
|
||||
) {
|
||||
this.restoreGamesScrollAfterOpen = false;
|
||||
void this.restoreGamesScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fresh open (router/share link): clear any stale origin. The openFrom*
|
||||
// helpers re-set it right after open() so back() routes home; the
|
||||
// return-from-stats path above skips this and keeps the origin intact.
|
||||
this.openedFrom = null;
|
||||
this.publicId = publicId;
|
||||
this.username = null;
|
||||
this.statsTree = null;
|
||||
this.gameHistoryCache = null;
|
||||
this.gamesScrollTop = 0;
|
||||
this.restoreGamesScrollAfterOpen = false;
|
||||
this.loading = publicId !== null;
|
||||
if (publicId !== null) {
|
||||
void this.loadProfile(publicId);
|
||||
@@ -104,35 +174,77 @@ export class PlayerProfileModal extends BaseModal {
|
||||
}
|
||||
|
||||
private async loadProfile(publicId: string): Promise<void> {
|
||||
const gen = ++this.loadGeneration;
|
||||
const profile = await fetchPublicPlayerProfile(publicId);
|
||||
// A late response must not clobber state after close or re-open.
|
||||
if (this.publicId !== publicId) return;
|
||||
// Drop a superseded response: a newer load started, or the modal moved to a
|
||||
// different player. onClose no longer clears publicId, so the id check alone
|
||||
// can't reject a stale same-player load started before an earlier close.
|
||||
if (gen !== this.loadGeneration || this.publicId !== publicId) return;
|
||||
this.loading = false;
|
||||
this.statsTree = profile === false ? null : profile.stats;
|
||||
this.username = profile === false ? null : (profile.username ?? null);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.publicId = null;
|
||||
this.username = null;
|
||||
this.statsTree = null;
|
||||
this.loading = false;
|
||||
this.openedFrom = null;
|
||||
// Intentionally preserves publicId/statsTree/history cache/scroll: the page
|
||||
// router closes this modal when the game-stats page opens on top, and the
|
||||
// return flow (returnToGames) needs that state intact. onOpen resets it for a
|
||||
// genuinely new player. Mirrors the account modal.
|
||||
protected onClose(): void {}
|
||||
|
||||
// Open the game-stats modal on top for a game in this player's history. Stash
|
||||
// the scroll offset so returning restores it (see returnToGames()).
|
||||
private openGameStats(gameId: string): void {
|
||||
this.gamesScrollTop = this.modalEl?.getScrollTop() ?? 0;
|
||||
const statsModal = document.querySelector<
|
||||
HTMLElement & { openFromProfile(gameId: string): void }
|
||||
>("game-stats-modal");
|
||||
statsModal?.openFromProfile(gameId);
|
||||
}
|
||||
|
||||
private viewGame(gameId: string): void {
|
||||
this.close();
|
||||
const encodedGameId = encodeURIComponent(gameId);
|
||||
const newUrl = `/${ClientEnv.workerPath(gameId)}/game/${encodedGameId}`;
|
||||
|
||||
history.pushState({ join: gameId }, "", newUrl);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }),
|
||||
);
|
||||
}
|
||||
|
||||
// Called by the game-stats modal's back button when it was opened from here.
|
||||
public returnToGames(): void {
|
||||
this.restoreGamesScrollAfterOpen = true;
|
||||
this.open({ publicID: this.publicId ?? undefined, tab: "games" });
|
||||
}
|
||||
|
||||
private async restoreGamesScroll(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this.modalEl?.updateComplete;
|
||||
const historyView = this.querySelector<
|
||||
HTMLElement & { updateComplete?: Promise<boolean> }
|
||||
>("player-game-history-view");
|
||||
await historyView?.updateComplete;
|
||||
this.modalEl?.setScrollTop(this.gamesScrollTop);
|
||||
}
|
||||
|
||||
// Origin is set after open() in every openFrom* helper: onOpen clears it, and
|
||||
// back() only reads it later on the user's click, so assigning here survives
|
||||
// to route the back button home (and survives the game-stats detour, which
|
||||
// never re-runs onOpen's fresh path).
|
||||
public openFromClan(publicId: string): void {
|
||||
this.openedFrom = "clan";
|
||||
this.open({ publicID: publicId });
|
||||
this.openedFrom = "clan";
|
||||
}
|
||||
|
||||
public openFromLeaderboard(publicId: string): void {
|
||||
this.openedFrom = "leaderboard";
|
||||
this.open({ publicID: publicId });
|
||||
this.openedFrom = "leaderboard";
|
||||
}
|
||||
|
||||
public openFromAccount(publicId: string): void {
|
||||
this.openedFrom = "account";
|
||||
this.open({ publicID: publicId });
|
||||
this.openedFrom = "account";
|
||||
}
|
||||
|
||||
private back(): void {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import {
|
||||
html,
|
||||
LitElement,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
type PlayerGameModeFilter,
|
||||
@@ -81,13 +86,25 @@ export class PlayerGameHistoryView extends LitElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.syncToPublicId();
|
||||
}
|
||||
|
||||
// Hydrate from the cache when it matches the current player, otherwise load
|
||||
// that player's history fresh.
|
||||
private syncToPublicId() {
|
||||
if (this.cachedState && this.cachedState.publicId === this.publicId) {
|
||||
this.games = this.cachedState.games;
|
||||
this.nextCursor = this.cachedState.nextCursor;
|
||||
this.typeFilter = this.cachedState.typeFilter;
|
||||
this.modeFilter = this.cachedState.modeFilter;
|
||||
this.appendFailed = false;
|
||||
this.loadState = "ok";
|
||||
} else if (this.publicId) {
|
||||
this.reload();
|
||||
// Fresh player → show the default (All) view rather than inheriting the
|
||||
// previous player's filters. No-op on first mount (already defaults).
|
||||
this.typeFilter = "all";
|
||||
this.modeFilter = "all";
|
||||
void this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +113,21 @@ export class PlayerGameHistoryView extends LitElement {
|
||||
this.teardownObserver();
|
||||
}
|
||||
|
||||
willUpdate(changed: PropertyValues) {
|
||||
// Reload when the host swaps in a different player after mount (e.g. a
|
||||
// manual hash edit routes the profile modal to a new publicId). Lit reuses
|
||||
// this element, so connectedCallback won't fire again. The undefined check
|
||||
// skips the initial render, which connectedCallback already handled.
|
||||
const previous = changed.get("publicId");
|
||||
if (
|
||||
changed.has("publicId") &&
|
||||
previous !== undefined &&
|
||||
previous !== this.publicId
|
||||
) {
|
||||
this.syncToPublicId();
|
||||
}
|
||||
}
|
||||
|
||||
updated() {
|
||||
// The IntersectionObserver target only exists when there's more to load AND
|
||||
// we're not mid-request — wire it up after each render so it tracks the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, PropertyValues, html } from "lit";
|
||||
import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas";
|
||||
import {
|
||||
@@ -74,6 +74,51 @@ export class PlayerStatsTreeView extends LitElement {
|
||||
: translateText("game_mode.teams");
|
||||
}
|
||||
|
||||
private labelForType(t: GameType | "Ranked") {
|
||||
return t === "Ranked"
|
||||
? translateText("player_stats_tree.ranked")
|
||||
: t === GameType.Public
|
||||
? translateText("player_stats_tree.public")
|
||||
: t === GameType.Private
|
||||
? translateText("player_stats_tree.private")
|
||||
: translateText("player_stats_tree.solo");
|
||||
}
|
||||
|
||||
// A full-width filter row styled like the game-history tab: a pill container
|
||||
// wrapping tab-style buttons that grow to fill the row.
|
||||
private renderFilterRow(buttons: TemplateResult[]): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex flex-wrap gap-1 p-1 bg-white/5 border border-white/10 rounded-xl"
|
||||
>
|
||||
${buttons}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFilterTab(
|
||||
label: string,
|
||||
isActive: boolean,
|
||||
onSelect: () => void,
|
||||
title?: string,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=${isActive}
|
||||
title=${title ?? nothing}
|
||||
@click=${onSelect}
|
||||
class="grow basis-20 px-3 py-1.5 text-xs font-bold uppercase tracking-wider whitespace-nowrap rounded-lg transition-colors ${isActive
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30"
|
||||
: "text-white/50 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private labelForRankedType(r: RankedType) {
|
||||
switch (r) {
|
||||
case RankedType.OneVOne:
|
||||
@@ -275,103 +320,58 @@ export class PlayerStatsTreeView extends LitElement {
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Filters -->
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center justify-between p-2 bg-black/20 rounded-lg border border-white/5"
|
||||
>
|
||||
<!-- Type selector -->
|
||||
<div class="flex gap-1">
|
||||
${types.map(
|
||||
(t) => html`
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded-md border font-bold uppercase tracking-wider transition-all duration-200 ${this
|
||||
.selectedType === t
|
||||
? "bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/40"
|
||||
: "bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white"}"
|
||||
@click=${() => this.setGameType(t)}
|
||||
>
|
||||
${t === "Ranked"
|
||||
? translateText("player_stats_tree.ranked")
|
||||
: t === GameType.Public
|
||||
? translateText("player_stats_tree.public")
|
||||
: t === GameType.Private
|
||||
? translateText("player_stats_tree.private")
|
||||
: translateText("player_stats_tree.solo")}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- Ranked type selector -->
|
||||
${this.selectedType === "Ranked" && rankedTypes.length
|
||||
? html`<div
|
||||
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
|
||||
>
|
||||
${rankedTypes.map(
|
||||
(r) => html`
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
|
||||
.selectedRankedType === r
|
||||
? "bg-white/20 text-white font-bold"
|
||||
: "text-gray-400 hover:text-white"}"
|
||||
@click=${() => this.setRankedType(r)}
|
||||
>
|
||||
${this.labelForRankedType(r)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
|
||||
<!-- Mode selector -->
|
||||
${modes.length
|
||||
? html`<div
|
||||
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
|
||||
>
|
||||
${modes.map(
|
||||
(m) => html`
|
||||
<button
|
||||
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
|
||||
.selectedMode === m
|
||||
? "bg-white/20 text-white font-bold"
|
||||
: "text-gray-400 hover:text-white"}"
|
||||
@click=${() => this.setMode(m)}
|
||||
title=${translateText("player_stats_tree.mode")}
|
||||
>
|
||||
${this.labelForMode(m)}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
|
||||
<!-- Difficulty selector -->
|
||||
${!this.shouldMergeDifficulties && diffs.length
|
||||
? html`<div
|
||||
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
|
||||
>
|
||||
${diffs.map(
|
||||
(d) =>
|
||||
html` <button
|
||||
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
|
||||
.selectedDifficulty === d
|
||||
? "bg-white/20 text-white font-bold"
|
||||
: "text-gray-400 hover:text-white"}"
|
||||
@click=${() => this.setDifficulty(d)}
|
||||
title=${translateText("difficulty.difficulty")}
|
||||
>
|
||||
${translateText(`difficulty.${d.toLowerCase()}`)}
|
||||
</button>`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
<!-- Filters: a type row, then a context-dependent second row (ranked
|
||||
type / mode / difficulty), styled like the game-history tab. -->
|
||||
<div class="space-y-2">
|
||||
${this.renderFilterRow(
|
||||
types.map((t) =>
|
||||
this.renderFilterTab(
|
||||
this.labelForType(t),
|
||||
this.selectedType === t,
|
||||
() => this.setGameType(t),
|
||||
),
|
||||
),
|
||||
)}
|
||||
${this.selectedType === "Ranked" && rankedTypes.length
|
||||
? this.renderFilterRow(
|
||||
rankedTypes.map((r) =>
|
||||
this.renderFilterTab(
|
||||
this.labelForRankedType(r) ?? "",
|
||||
this.selectedRankedType === r,
|
||||
() => this.setRankedType(r),
|
||||
),
|
||||
),
|
||||
)
|
||||
: nothing}
|
||||
${modes.length
|
||||
? this.renderFilterRow(
|
||||
modes.map((m) =>
|
||||
this.renderFilterTab(
|
||||
this.labelForMode(m),
|
||||
this.selectedMode === m,
|
||||
() => this.setMode(m),
|
||||
translateText("player_stats_tree.mode"),
|
||||
),
|
||||
),
|
||||
)
|
||||
: nothing}
|
||||
${!this.shouldMergeDifficulties && diffs.length
|
||||
? this.renderFilterRow(
|
||||
diffs.map((d) =>
|
||||
this.renderFilterTab(
|
||||
translateText(`difficulty.${d.toLowerCase()}`),
|
||||
this.selectedDifficulty === d,
|
||||
() => this.setDifficulty(d),
|
||||
translateText("difficulty.difficulty"),
|
||||
),
|
||||
),
|
||||
)
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${leaf
|
||||
? html`
|
||||
<div class="space-y-6 mt-2">
|
||||
<div class="space-y-6 border-t border-white/10 pt-3">
|
||||
<player-stats-grid
|
||||
.titles=${[
|
||||
translateText("player_stats_tree.stats_wins"),
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const copyToClipboardMock = vi.hoisted(() =>
|
||||
vi.fn(async (_text: string, onSuccess?: () => void) => onSuccess?.()),
|
||||
);
|
||||
|
||||
vi.mock("../../src/client/Api", () => ({
|
||||
fetchPublicPlayerProfile: vi.fn(async () => ({
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
stats: { Public: { "Free For All": {} } },
|
||||
})),
|
||||
fetchPublicPlayerGames: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("src/client/ClientEnv", () => ({
|
||||
ClientEnv: { workerPath: vi.fn(() => "w0") },
|
||||
}));
|
||||
|
||||
vi.mock("../../src/client/TerrainMapFileLoader", () => ({
|
||||
terrainMapFileLoader: {
|
||||
getMapData: vi.fn(() => ({ webpPath: "/maps/world.webp" })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/client/Utils", () => ({
|
||||
getMapName: vi.fn((name: string) => name),
|
||||
renderDuration: vi.fn((seconds: number) => `${seconds}s`),
|
||||
translateText: vi.fn((key: string) => key),
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => {
|
||||
class FakeGameInfoView extends HTMLElement {}
|
||||
if (!customElements.get("game-info-view")) {
|
||||
customElements.define("game-info-view", FakeGameInfoView);
|
||||
}
|
||||
return { GameInfoView: FakeGameInfoView };
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
"../../src/client/components/baseComponents/stats/PlayerStatsTree",
|
||||
() => {
|
||||
class FakePlayerStatsTreeView extends HTMLElement {}
|
||||
if (!customElements.get("player-stats-tree-view")) {
|
||||
customElements.define("player-stats-tree-view", FakePlayerStatsTreeView);
|
||||
}
|
||||
return { PlayerStatsTreeView: FakePlayerStatsTreeView };
|
||||
},
|
||||
);
|
||||
|
||||
class FakeIntersectionObserver {
|
||||
constructor(_callback: IntersectionObserverCallback) {}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
root = null;
|
||||
rootMargin = "";
|
||||
thresholds = [];
|
||||
}
|
||||
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
|
||||
|
||||
import { fetchPublicPlayerGames } from "../../src/client/Api";
|
||||
import { GameStatsModal } from "../../src/client/GameStatsModal";
|
||||
import { modalRouter } from "../../src/client/ModalRouter";
|
||||
import { initNavigation } from "../../src/client/Navigation";
|
||||
import { PlayerProfileModal } from "../../src/client/PlayerProfileModal";
|
||||
import {
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
} from "../../src/core/game/Game";
|
||||
import { setup } from "../util/Setup";
|
||||
|
||||
type ModalShell = HTMLElement & {
|
||||
getScrollTop(): number;
|
||||
setScrollTop(value: number): void;
|
||||
updateComplete: Promise<boolean>;
|
||||
};
|
||||
|
||||
async function waitForProfile(
|
||||
modal: PlayerProfileModal,
|
||||
assertion: () => void,
|
||||
): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
await modal.updateComplete;
|
||||
const shell = modal.querySelector("o-modal") as ModalShell | null;
|
||||
await shell?.updateComplete;
|
||||
const history = modal.querySelector("player-game-history-view") as
|
||||
| (HTMLElement & { updateComplete: Promise<boolean> })
|
||||
| null;
|
||||
await history?.updateComplete;
|
||||
assertion();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForStatsModal(
|
||||
modal: GameStatsModal,
|
||||
assertion: () => void,
|
||||
): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
await modal.updateComplete;
|
||||
const shell = modal.querySelector("o-modal") as ModalShell | null;
|
||||
await shell?.updateComplete;
|
||||
assertion();
|
||||
});
|
||||
}
|
||||
|
||||
describe("Profile Games stats navigation", () => {
|
||||
let modal: PlayerProfileModal;
|
||||
let statsModal: GameStatsModal;
|
||||
let playPage: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
playPage = document.createElement("div");
|
||||
playPage.id = "page-play";
|
||||
document.body.appendChild(playPage);
|
||||
initNavigation();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
playPage.remove();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const game = await setup(
|
||||
"world",
|
||||
{
|
||||
gameMap: GameMapType.World,
|
||||
gameMode: GameMode.FFA,
|
||||
gameType: GameType.Public,
|
||||
},
|
||||
[new PlayerInfo("Player", PlayerType.Human, null, "player-1")],
|
||||
);
|
||||
const player = game.player("player-1");
|
||||
game.setWinner(player, game.stats().stats());
|
||||
const gameConfig = game.config().gameConfig();
|
||||
vi.mocked(fetchPublicPlayerGames).mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
gameId: "game-1",
|
||||
start: "2026-07-01T12:00:00.000Z",
|
||||
durationSeconds: game.elapsedGameSeconds(),
|
||||
map: gameConfig.gameMap,
|
||||
mode: gameConfig.gameMode,
|
||||
type: gameConfig.gameType.toLowerCase(),
|
||||
playerTeams: gameConfig.playerTeams?.toString() ?? null,
|
||||
rankedType: gameConfig.rankedType ?? "",
|
||||
result: game.getWinner() === player ? "victory" : "defeat",
|
||||
totalPlayers: game.players().length,
|
||||
username: player.name(),
|
||||
clanTag: player.info().clanTag,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) });
|
||||
history.replaceState(null, "", "/");
|
||||
modalRouter.register("profile", {
|
||||
tag: "player-profile-modal",
|
||||
pageId: "page-profile",
|
||||
});
|
||||
modalRouter.register("stats", {
|
||||
tag: "game-stats-modal",
|
||||
pageId: "page-stats",
|
||||
});
|
||||
if (!customElements.get("player-profile-modal")) {
|
||||
customElements.define("player-profile-modal", PlayerProfileModal);
|
||||
}
|
||||
if (!customElements.get("game-stats-modal")) {
|
||||
customElements.define("game-stats-modal", GameStatsModal);
|
||||
}
|
||||
|
||||
modal = document.createElement(
|
||||
"player-profile-modal",
|
||||
) as PlayerProfileModal;
|
||||
modal.id = "page-profile";
|
||||
modal.setAttribute("inline", "");
|
||||
modal.className = "hidden page-content";
|
||||
document.body.appendChild(modal);
|
||||
|
||||
statsModal = document.createElement("game-stats-modal") as GameStatsModal;
|
||||
statsModal.id = "page-stats";
|
||||
statsModal.setAttribute("inline", "");
|
||||
statsModal.className = "hidden page-content";
|
||||
document.body.appendChild(statsModal);
|
||||
|
||||
await modal.updateComplete;
|
||||
await statsModal.updateComplete;
|
||||
|
||||
history.replaceState(null, "", "/#modal=profile&publicID=player-1");
|
||||
expect(modalRouter.routeFromHash()).toBe(true);
|
||||
await waitForProfile(modal, () => {
|
||||
expect(modal.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
modal.setActiveTab("games");
|
||||
await waitForProfile(modal, () => {
|
||||
expect(modal.querySelector("player-game-history-view")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.showPage?.("page-play");
|
||||
modal.remove();
|
||||
statsModal.remove();
|
||||
history.replaceState(null, "", "/");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("drills into Stats and returns to the cached Games scroll position", async () => {
|
||||
const shell = modal.querySelector("o-modal") as ModalShell;
|
||||
shell.setScrollTop(420);
|
||||
|
||||
const statsButton = Array.from(modal.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "game_list.stats",
|
||||
);
|
||||
expect(statsButton).toBeTruthy();
|
||||
statsButton!.click();
|
||||
await waitForStatsModal(statsModal, () => {
|
||||
expect(statsModal.isOpen()).toBe(true);
|
||||
expect(statsModal.querySelector("game-info-view")).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(modal.isOpen()).toBe(false);
|
||||
const statsView = statsModal.querySelector(
|
||||
"game-info-view",
|
||||
) as HTMLElement & { gameId: string };
|
||||
expect(statsView.gameId).toBe("game-1");
|
||||
expect(window.location.hash).toBe("#modal=stats&gameID=game-1");
|
||||
|
||||
const backButton = statsModal.querySelector(
|
||||
'[slot="header"] button',
|
||||
) as HTMLButtonElement;
|
||||
backButton.click();
|
||||
await waitForProfile(modal, () => {
|
||||
expect(modal.querySelector("player-game-history-view")).not.toBeNull();
|
||||
expect(shell.getScrollTop()).toBe(420);
|
||||
});
|
||||
expect(window.location.hash).toBe(
|
||||
"#modal=profile&publicID=player-1&tab=games",
|
||||
);
|
||||
|
||||
// History wasn't refetched on return — the cached list was reused.
|
||||
expect(fetchPublicPlayerGames).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reloads the games history when the route changes to a different player", async () => {
|
||||
// beforeEach opened player-1's history.
|
||||
expect(fetchPublicPlayerGames).toHaveBeenCalledWith(
|
||||
"player-1",
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Editing the hash to a different player re-routes the already-open modal.
|
||||
// Lit reuses the mounted history view, so it must load the new player's
|
||||
// games rather than keep showing player-1's.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/#modal=profile&publicID=player-2&tab=games",
|
||||
);
|
||||
expect(modalRouter.routeFromHash()).toBe(true);
|
||||
|
||||
await waitForProfile(modal, () => {
|
||||
expect(fetchPublicPlayerGames).toHaveBeenCalledWith(
|
||||
"player-2",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets filters to the default view when the route changes players", async () => {
|
||||
// Pick a non-default type filter on player-1 (translateText is mocked to
|
||||
// echo the key, so the Private tab's text is its label key).
|
||||
const privateTab = Array.from(modal.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "account_modal.games_type_private",
|
||||
);
|
||||
expect(privateTab).toBeTruthy();
|
||||
privateTab!.click();
|
||||
await waitForProfile(modal, () => {
|
||||
const call = vi
|
||||
.mocked(fetchPublicPlayerGames)
|
||||
.mock.calls.find(
|
||||
(c) => c[0] === "player-1" && c[1]?.type === "private",
|
||||
);
|
||||
expect(call).toBeTruthy();
|
||||
});
|
||||
|
||||
// Routing to a different player must fall back to the default (All) view
|
||||
// rather than inheriting player-1's Private filter.
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/#modal=profile&publicID=player-2&tab=games",
|
||||
);
|
||||
expect(modalRouter.routeFromHash()).toBe(true);
|
||||
|
||||
await waitForProfile(modal, () => {
|
||||
const player2Calls = vi
|
||||
.mocked(fetchPublicPlayerGames)
|
||||
.mock.calls.filter((c) => c[0] === "player-2");
|
||||
// Every player-2 request (there should only be the one reset reload) must
|
||||
// drop player-1's filters — asserting across all of them catches a stale
|
||||
// first request even if a later one happens to be clean.
|
||||
expect(player2Calls.length).toBeGreaterThan(0);
|
||||
for (const [, opts] of player2Calls) {
|
||||
expect(opts?.type).toBeUndefined();
|
||||
expect(opts?.filter).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user