("account-modal")
+ ?.returnToGames();
+ } else if (openedFrom === "clan") {
+ document
+ .querySelector<
+ HTMLElement & { returnToGameHistory(): void }
+ >("clan-modal")
+ ?.returnToGameHistory();
+ }
}
}
diff --git a/src/client/components/clan/ClanGameHistoryView.ts b/src/client/components/clan/ClanGameHistoryView.ts
index cc501f300..c39ebd8c6 100644
--- a/src/client/components/clan/ClanGameHistoryView.ts
+++ b/src/client/components/clan/ClanGameHistoryView.ts
@@ -197,6 +197,16 @@ export class ClanGameHistoryView extends LitElement {
}
}
+ private showStats(gameId: string) {
+ this.dispatchEvent(
+ new CustomEvent<{ gameId: string }>("view-stats", {
+ detail: { gameId },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
render() {
if (this.loadState === "forbidden") {
return html`
@@ -423,13 +433,22 @@ export class ClanGameHistoryView extends LitElement {
.showVisibilityToggle=${false}
>
-
+
+
+
+
apiMockFactory());
+vi.mock("../../src/client/ClanApi", () => clanApiMockFactory());
+
+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`),
+ showToast: vi.fn(),
+ translateText: vi.fn((key: string) => key),
+}));
+
+vi.mock("../../src/client/components/CopyButton", () => ({}));
+
+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 };
+});
+
+class FakeIntersectionObserver {
+ constructor(_callback: IntersectionObserverCallback) {}
+ observe() {}
+ disconnect() {}
+ unobserve() {}
+ takeRecords() {
+ return [];
+ }
+ root = null;
+ rootMargin = "";
+ thresholds = [];
+}
+vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
+
+import { fetchClanGames } from "../../src/client/ClanApi";
+import { ClanModal } from "../../src/client/ClanModal";
+import { GameStatsModal } from "../../src/client/GameStatsModal";
+import { modalRouter } from "../../src/client/ModalRouter";
+import { initNavigation } from "../../src/client/Navigation";
+
+type ModalShell = HTMLElement & {
+ getScrollTop(): number;
+ setScrollTop(value: number): void;
+ updateComplete: Promise;
+};
+
+async function waitForClanModal(
+ modal: ClanModal,
+ assertion: () => void,
+): Promise {
+ await vi.waitFor(async () => {
+ await modal.updateComplete;
+ const shell = modal.querySelector("o-modal") as ModalShell | null;
+ await shell?.updateComplete;
+ const gameHistory = modal.querySelector("clan-game-history-view") as
+ | (HTMLElement & { updateComplete: Promise })
+ | null;
+ await gameHistory?.updateComplete;
+ assertion();
+ });
+}
+
+async function waitForStatsModal(
+ modal: GameStatsModal,
+ assertion: () => void,
+): Promise {
+ await vi.waitFor(async () => {
+ await modal.updateComplete;
+ const shell = modal.querySelector("o-modal") as ModalShell | null;
+ await shell?.updateComplete;
+ assertion();
+ });
+}
+
+describe("Clan Games stats navigation", () => {
+ let modal: ClanModal;
+ 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();
+ (fetchClanGames as ReturnType).mockResolvedValue({
+ results: [
+ {
+ gameId: "clan-game-1",
+ start: "2026-07-01T12:00:00.000Z",
+ durationSeconds: 600,
+ map: "World",
+ mode: "Team",
+ playerTeams: "Duos",
+ rankedType: undefined,
+ result: "victory",
+ totalPlayers: 8,
+ clanPlayers: [
+ { publicId: "player-1", username: "Player", won: true },
+ ],
+ },
+ ],
+ nextCursor: null,
+ });
+ history.replaceState(null, "", "/");
+ modalRouter.register("clan", {
+ tag: "clan-modal",
+ pageId: "page-clan",
+ });
+ modalRouter.register("stats", {
+ tag: "game-stats-modal",
+ pageId: "page-stats",
+ });
+ if (!customElements.get("clan-modal")) {
+ customElements.define("clan-modal", ClanModal);
+ }
+ if (!customElements.get("game-stats-modal")) {
+ customElements.define("game-stats-modal", GameStatsModal);
+ }
+
+ modal = document.createElement("clan-modal") as ClanModal;
+ modal.id = "page-clan";
+ 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;
+ window.showPage?.("page-clan");
+ modal.open({ clan: "TST" });
+ await waitForClanModal(modal, () => {
+ const shell = modal.querySelector("o-modal") as ModalShell;
+ expect(shell.shadowRoot?.textContent).toContain(
+ "clan_modal.tab_game_history",
+ );
+ });
+ modal.setActiveTab("game-history");
+ await waitForClanModal(modal, () => {
+ expect(modal.querySelector("clan-game-history-view")).not.toBeNull();
+ expect(modal.textContent).toContain("game_list.stats");
+ });
+ });
+
+ afterEach(() => {
+ window.showPage?.("page-play");
+ modal.remove();
+ statsModal.remove();
+ history.replaceState(null, "", "/");
+ });
+
+ it("drills into Stats and returns to the cached clan 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("clan-game-1");
+ expect(window.location.hash).toBe("#modal=stats&gameID=clan-game-1");
+
+ const backButton = statsModal.querySelector(
+ '[slot="header"] button',
+ ) as HTMLButtonElement;
+ backButton.click();
+ await waitForClanModal(modal, () => {
+ expect(modal.isOpen()).toBe(true);
+ expect(modal.querySelector("clan-game-history-view")).not.toBeNull();
+ expect(shell.getScrollTop()).toBe(420);
+ });
+ expect(window.location.hash).toBe("#modal=clan&clan=TST&tab=game-history");
+
+ expect(fetchClanGames).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/client/clan/ClanGameHistoryView.test.ts b/tests/client/clan/ClanGameHistoryView.test.ts
index fd2ac4a8a..4e53c144a 100644
--- a/tests/client/clan/ClanGameHistoryView.test.ts
+++ b/tests/client/clan/ClanGameHistoryView.test.ts
@@ -536,6 +536,37 @@ describe("ClanGameHistoryView", () => {
});
});
+ describe("viewStats", () => {
+ it("emits a bubbling, composed view-stats event with the game id", async () => {
+ mockFetch(() =>
+ Promise.resolve(okPage([makeGame({ gameId: "stats-game" })])),
+ );
+ const el = await mountView();
+ await flushAsync(el);
+
+ const statsButton = Array.from(el.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "game_list.stats",
+ );
+ expect(statsButton).toBeTruthy();
+
+ const events: CustomEvent<{ gameId: string }>[] = [];
+ const handleViewStats = (event: Event) => {
+ events.push(event as CustomEvent<{ gameId: string }>);
+ };
+ document.body.addEventListener("view-stats", handleViewStats);
+ try {
+ statsButton!.click();
+ } finally {
+ document.body.removeEventListener("view-stats", handleViewStats);
+ }
+
+ expect(events).toHaveLength(1);
+ expect(events[0].detail).toEqual({ gameId: "stats-game" });
+ expect(events[0].bubbles).toBe(true);
+ expect(events[0].composed).toBe(true);
+ });
+ });
+
describe("watchReplay", () => {
it("pushes a /game/:id URL and emits close-clan-modal + join-changed", async () => {
mockFetch(() =>