From a14cf0edc1a948cf0cc047294f8788bdb9c596e4 Mon Sep 17 00:00:00 2001
From: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
Date: Fri, 22 May 2026 22:30:16 +0100
Subject: [PATCH] Clan Game History (#3988)
## Description:
Adds
continuation of infra https://github.com/openfrontio/infra/pull/345
## 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:
w.o.n
---
resources/lang/en.json | 56 +-
src/client/ClanApi.ts | 61 +-
src/client/ClanModal.ts | 124 ++-
src/client/Utils.ts | 25 +-
src/client/components/clan/ClanDetailView.ts | 146 ++--
.../components/clan/ClanGameHistoryView.ts | 716 ++++++++++++++++++
src/client/components/clan/ClanShared.ts | 13 -
src/core/ClanApiSchemas.ts | 59 +-
tests/client/clan/ClanApiQueries.test.ts | 180 +++--
tests/client/clan/ClanApiSchemas.test.ts | 219 ++++--
tests/client/clan/ClanGameHistoryView.test.ts | 605 +++++++++++++++
tests/client/clan/ClanModal.handlers.test.ts | 7 +-
tests/client/clan/ClanModal.rendering.test.ts | 11 +-
tests/client/clan/ClanModalTestUtils.ts | 29 +-
14 files changed, 1979 insertions(+), 272 deletions(-)
create mode 100644 src/client/components/clan/ClanGameHistoryView.ts
create mode 100644 tests/client/clan/ClanGameHistoryView.test.ts
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 9bc8fe3f6..d8456c6cd 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -27,7 +27,22 @@
"click_to_copy": "Click to copy",
"enabled": "Enabled",
"disabled": "Disabled",
- "map_default": "Map default"
+ "map_default": "Map default",
+ "month_jan": "January",
+ "month_feb": "February",
+ "month_mar": "March",
+ "month_apr": "April",
+ "month_may": "May",
+ "month_jun": "June",
+ "month_jul": "July",
+ "month_aug": "August",
+ "month_sep": "September",
+ "month_oct": "October",
+ "month_nov": "November",
+ "month_dec": "December",
+ "duration_hour_short": "h",
+ "duration_minute_short": "min",
+ "duration_second_short": "s"
},
"main": {
"title": "OpenFront (ALPHA)",
@@ -285,7 +300,6 @@
"no_pending_applications": "No pending applications.",
"applied": "Applied",
"cancel_request": "Cancel",
- "statistics": "Statistics",
"stats_total": "Total",
"stats_ffa": "FFA",
"stats_team": "Teams",
@@ -340,7 +354,43 @@
"error_rate_limited_generic": "Please wait before joining another clan",
"error_network": "Network error",
"error_failed": "Action failed",
- "error_loading": "Failed to load"
+ "error_loading": "Failed to load",
+ "tab_overview": "Overview",
+ "tab_members": "Members",
+ "tab_game_history": "Game History",
+ "members_visible_to_members": "Member list is visible to clan members only.",
+ "history_empty": "No games played yet.",
+ "history_unavailable": "Game history is unavailable.",
+ "history_members_only": "Game history is visible to clan members only.",
+ "history_watch_replay": "Watch Replay",
+ "history_result_victory": "Victory!",
+ "history_result_defeat": "Defeat",
+ "history_result_partial": "{wins} / {total} Won",
+ "history_clan_winners": "Clan Member Winners",
+ "history_clan_members": "Clan Members",
+ "history_filter_all": "All",
+ "history_filter_hvn": "HvN",
+ "history_filter_ranked": "Ranked",
+ "history_game_type": "Game Type",
+ "history_type_ffa": "FFA",
+ "history_type_team": "Team",
+ "history_type_hvn": "Humans vs Nations",
+ "history_type_duos": "Duos",
+ "history_type_trios": "Trios",
+ "history_type_quads": "Quads",
+ "history_type_n_teams": "{count, plural, one {# Team} other {# Teams}}",
+ "history_type_ranked": "Ranked {ranked}",
+ "history_map": "Map",
+ "history_clan_players": "Clan players",
+ "history_clan_players_value": "{clanCount} / {total} total",
+ "history_players": "Players",
+ "history_duration": "Duration",
+ "history_game_id": "Game ID",
+ "history_today_at": "Today at {time}",
+ "history_yesterday": "Yesterday",
+ "history_today": "Today",
+ "history_load_more_failed": "Couldn't load more games.",
+ "history_end_of_history": "End of history."
},
"account_modal": {
"title": "Account",
diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts
index c531b383c..9b7f62000 100644
--- a/src/client/ClanApi.ts
+++ b/src/client/ClanApi.ts
@@ -3,6 +3,9 @@ import {
ClanBansResponseSchema,
type ClanBrowseResponse,
ClanBrowseResponseSchema,
+ type ClanGameFilter,
+ type ClanGamesResponse,
+ ClanGamesResponseSchema,
type ClanInfo,
ClanInfoSchema,
type ClanLeaderboardResponse,
@@ -11,8 +14,6 @@ import {
ClanMembersResponseSchema,
type ClanRequestsResponse,
ClanRequestsResponseSchema,
- type ClanStats,
- ClanStatsSchema,
JoinClanResponseSchema,
} from "../core/ClanApiSchemas";
import { getApiBase } from "./Api";
@@ -21,6 +22,11 @@ export type {
ClanBan,
ClanBansResponse,
ClanBrowseResponse,
+ ClanGame,
+ ClanGameFilter,
+ ClanGamePlayer,
+ ClanGameResult,
+ ClanGamesResponse,
ClanInfo,
ClanJoinRequest,
ClanMember,
@@ -28,7 +34,6 @@ export type {
ClanMemberStats,
ClanMemberWL,
ClanRequestsResponse,
- ClanStats,
} from "../core/ClanApiSchemas";
async function clanFetch(
@@ -80,26 +85,6 @@ export async function fetchClanLeaderboard(): Promise<
}
}
-export async function fetchClanStats(tag: string): Promise {
- try {
- const res = await fetch(
- `${getApiBase()}/public/clan/${encodeURIComponent(tag)}`,
- { headers: { Accept: "application/json" } },
- );
- if (!res.ok) return false;
- const json = await res.json();
- const parsed = ClanStatsSchema.safeParse(json?.clan);
- if (!parsed.success) {
- console.warn("fetchClanStats: Zod validation failed", parsed.error);
- return false;
- }
- return parsed.data;
- } catch (err) {
- console.warn("fetchClanStats: request failed", err);
- return false;
- }
-}
-
export async function fetchClans(
search?: string,
page = 1,
@@ -468,6 +453,36 @@ export async function unbanClanMember(
}
}
+export type ClanGamesFetchError = "forbidden" | "failed";
+
+export async function fetchClanGames(
+ tag: string,
+ opts: { filter?: ClanGameFilter; cursor?: string } = {},
+): Promise {
+ try {
+ const params = new URLSearchParams();
+ if (opts.filter) params.set("filter", opts.filter);
+ // `cursor` is an opaque continuation token issued by the previous
+ // response's `nextCursor`. Round-trip verbatim; never construct.
+ if (opts.cursor) params.set("cursor", opts.cursor);
+ const qs = params.toString();
+ const res = await clanFetch(
+ `/clans/${encodeURIComponent(tag)}/games${qs ? `?${qs}` : ""}`,
+ );
+ if (res.status === 403) return { error: "forbidden" };
+ if (!res.ok) return { error: "failed" };
+ const json = await res.json();
+ const parsed = ClanGamesResponseSchema.safeParse(json);
+ if (!parsed.success) {
+ console.warn("fetchClanGames: Zod validation failed", parsed.error);
+ return { error: "failed" };
+ }
+ return parsed.data;
+ } catch {
+ return { error: "failed" };
+ }
+}
+
export async function fetchClanBans(
tag: string,
page = 1,
diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts
index 975367c2b..b161e3c73 100644
--- a/src/client/ClanModal.ts
+++ b/src/client/ClanModal.ts
@@ -1,13 +1,15 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getUserMe, invalidateUserMe } from "./Api";
-import { type ClanInfo, type ClanMember, type ClanStats } from "./ClanApi";
+import { type ClanInfo, type ClanMember } from "./ClanApi";
import { BaseModal } from "./components/BaseModal";
import "./components/clan/ClanBansView";
import "./components/clan/ClanBrowseView";
import type { BrowseState } from "./components/clan/ClanBrowseView";
import "./components/clan/ClanCard";
import "./components/clan/ClanDetailView";
+import "./components/clan/ClanGameHistoryView";
+import type { ClanGameHistoryCache } from "./components/clan/ClanGameHistoryView";
import "./components/clan/ClanManageView";
import "./components/clan/ClanMyRequestsView";
import "./components/clan/ClanRequestsView";
@@ -27,6 +29,15 @@ type View =
| "bans"
| "my-requests";
+// List tabs share BaseModal's `activeTab` slot with detail tabs ("overview" /
+// "members" / "game-history"); which set is live depends on `view`.
+const LIST_TABS = ["my-clans", "browse"] as const;
+type ListTab = (typeof LIST_TABS)[number];
+
+function isListTab(key: string): key is ListTab {
+ return (LIST_TABS as readonly string[]).includes(key);
+}
+
@customElement("clan-modal")
export class ClanModal extends BaseModal {
protected routerName = "clan";
@@ -56,13 +67,23 @@ export class ClanModal extends BaseModal {
members: ClanMember[];
membersTotal: number;
pendingRequestCount: number;
- stats: ClanStats | null;
} | null = null;
+ // Single-clan cache: switching clans within one modal session drops
+ // it (see `openDetail`), so a user who clan-hops loses their filter
+ // selection and accumulated scroll on the previous clan. Keyed-by-tag
+ // would persist across hops if that becomes desired.
+ private gameHistoryCache: ClanGameHistoryCache | null = null;
+ private previousListTab: ListTab = "my-clans";
+
private get onListView(): boolean {
return this.view === "list" && !this.selectedClanTag;
}
+ private get onDetailView(): boolean {
+ return this.view === "detail" && !!this.selectedClanTag;
+ }
+
protected modalConfig() {
return {
tabs: this.onListView
@@ -70,7 +91,22 @@ export class ClanModal extends BaseModal {
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
]
- : [],
+ : this.onDetailView
+ ? [
+ {
+ key: "overview",
+ label: translateText("clan_modal.tab_overview"),
+ },
+ {
+ key: "members",
+ label: translateText("clan_modal.tab_members"),
+ },
+ {
+ key: "game-history",
+ label: translateText("clan_modal.tab_game_history"),
+ },
+ ]
+ : [],
};
}
@@ -89,12 +125,19 @@ export class ClanModal extends BaseModal {
}
protected onTabEnter(tab: string): void {
- this.view = "list";
- this.selectedClan = null;
- this.selectedClanTag = "";
- if (tab === "my-clans") {
- this.loadMyClans();
+ if (isListTab(tab)) {
+ this.view = "list";
+ this.selectedClan = null;
+ this.selectedClanTag = "";
+ this.detailCache = null;
+ this.gameHistoryCache = null;
+ if (tab === "my-clans") {
+ this.loadMyClans();
+ }
+ return;
}
+ // Detail tabs: BaseModal already updated activeTab; renderInner reads it.
+ // No additional side effects required here.
}
private tagPill(tag: string) {
@@ -152,6 +195,8 @@ export class ClanModal extends BaseModal {
this.selectedClanTag = "";
this.myRole = null;
this.detailCache = null;
+ this.gameHistoryCache = null;
+ this.setActiveTab(this.previousListTab);
},
ariaLabel,
rightContent: clan ? this.tagPill(clan.tag) : undefined,
@@ -164,12 +209,14 @@ export class ClanModal extends BaseModal {
protected onClose(): void {
this.activeTab = "my-clans";
+ this.previousListTab = "my-clans";
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
this.myRole = null;
this.browseCache = null;
this.detailCache = null;
+ this.gameHistoryCache = null;
}
private async loadMyClans() {
@@ -257,7 +304,7 @@ export class ClanModal extends BaseModal {
this.myRole = null;
this.detailCache = null;
this.view = "list";
- this.loadMyClans();
+ this.setActiveTab(this.previousListTab);
}}
>`;
}
@@ -295,13 +342,26 @@ export class ClanModal extends BaseModal {
@navigate-back=${() => (this.view = "manage")}
>`;
}
- // Default: detail view
+ // Default: detail view — dispatched by the active detail tab
+ if (this.activeTab === "game-history") {
+ return html`) => {
+ this.gameHistoryCache = e.detail;
+ }}
+ @close-clan-modal=${() => this.close()}
+ >`;
+ }
return html`,
) => {
this.selectedClan = e.detail.clan;
@@ -329,7 +390,25 @@ export class ClanModal extends BaseModal {
members: e.detail.members,
membersTotal: e.detail.membersTotal,
pendingRequestCount: e.detail.pendingRequestCount,
- stats: e.detail.stats,
+ };
+ }}
+ @members-loaded=${(
+ e: CustomEvent<{
+ members: ClanMember[];
+ membersTotal: number;
+ pendingRequestCount: number;
+ }>,
+ ) => {
+ if (
+ !this.detailCache ||
+ this.detailCache.tag !== this.selectedClanTag
+ )
+ return;
+ this.detailCache = {
+ ...this.detailCache,
+ members: e.detail.members,
+ membersTotal: e.detail.membersTotal,
+ pendingRequestCount: e.detail.pendingRequestCount,
};
}}
@navigate-manage=${() => (this.view = "manage")}
@@ -339,6 +418,7 @@ export class ClanModal extends BaseModal {
...this.myClanRoles,
[e.detail.tag, "member" as ClanRole],
]);
+ this.detailCache = null;
this.openDetail(e.detail.tag);
}}
@clan-left=${(e: CustomEvent<{ tag: string }>) => {
@@ -350,7 +430,7 @@ export class ClanModal extends BaseModal {
this.myRole = null;
this.detailCache = null;
this.view = "list";
- this.loadMyClans();
+ this.setActiveTab(this.previousListTab);
}}
@request-sent=${(e: CustomEvent<{ tag: string; name: string }>) => {
this.myPendingRequests = [
@@ -383,8 +463,24 @@ export class ClanModal extends BaseModal {
}
private openDetail(tag: string) {
+ if (this.selectedClanTag !== tag) {
+ // History cache is per-clan (see `gameHistoryCache` declaration),
+ // so it must be cleared on tag change. `detailCache` is left
+ // alone — its `tag` field is checked at render time and the
+ // detail view falls back to a fresh fetch when it doesn't match,
+ // so an explicit null here would be redundant.
+ this.gameHistoryCache = null;
+ }
+ // Remember which list tab the user was on so the back button can
+ // return them to it (browse vs my-clans).
+ if (isListTab(this.activeTab)) {
+ this.previousListTab = this.activeTab;
+ }
this.selectedClanTag = tag;
this.view = "detail";
+ // modalConfig() returns detail tabs; setActiveTab anchors activeTab to
+ // "overview" and syncs the URL router (routerName = "clan").
+ this.setActiveTab("overview");
}
private renderMyClans() {
@@ -398,7 +494,7 @@ export class ClanModal extends BaseModal {
${translateText("clan_modal.no_clans")}