mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 11:42:15 +00:00
Clan Game History (#3988)
## Description: Adds <img width="1046" height="901" alt="image" src="https://github.com/user-attachments/assets/930b0d27-4707-4836-b068-620346e7e3a7" /> 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
This commit is contained in:
+38
-23
@@ -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<ClanStats | false> {
|
||||
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<ClanGamesResponse | { error: ClanGamesFetchError }> {
|
||||
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,
|
||||
|
||||
+110
-14
@@ -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);
|
||||
}}
|
||||
></clan-manage-view>`;
|
||||
}
|
||||
@@ -295,13 +342,26 @@ export class ClanModal extends BaseModal {
|
||||
@navigate-back=${() => (this.view = "manage")}
|
||||
></clan-bans-view>`;
|
||||
}
|
||||
// Default: detail view
|
||||
// Default: detail view — dispatched by the active detail tab
|
||||
if (this.activeTab === "game-history") {
|
||||
return html`<clan-game-history-view
|
||||
.clanTag=${this.selectedClanTag}
|
||||
.cachedState=${this.gameHistoryCache?.tag === this.selectedClanTag
|
||||
? this.gameHistoryCache
|
||||
: null}
|
||||
@history-updated=${(e: CustomEvent<ClanGameHistoryCache>) => {
|
||||
this.gameHistoryCache = e.detail;
|
||||
}}
|
||||
@close-clan-modal=${() => this.close()}
|
||||
></clan-game-history-view>`;
|
||||
}
|
||||
return html`<clan-detail-view
|
||||
.clanTag=${this.selectedClanTag}
|
||||
.cachedClan=${this.selectedClan}
|
||||
.myPublicId=${this.myPublicId}
|
||||
.myClanRoles=${this.myClanRoles}
|
||||
.myPendingRequests=${this.myPendingRequests}
|
||||
.detailTab=${this.activeTab === "members" ? "members" : "overview"}
|
||||
.cachedDetail=${this.detailCache?.tag === this.selectedClanTag
|
||||
? this.detailCache
|
||||
: null}
|
||||
@@ -311,6 +371,8 @@ export class ClanModal extends BaseModal {
|
||||
this.selectedClanTag = "";
|
||||
this.myRole = null;
|
||||
this.detailCache = null;
|
||||
this.gameHistoryCache = null;
|
||||
this.setActiveTab(this.previousListTab);
|
||||
}}
|
||||
@detail-loaded=${(
|
||||
e: CustomEvent<{
|
||||
@@ -319,7 +381,6 @@ export class ClanModal extends BaseModal {
|
||||
members: ClanMember[];
|
||||
membersTotal: number;
|
||||
pendingRequestCount: number;
|
||||
stats: ClanStats | null;
|
||||
}>,
|
||||
) => {
|
||||
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")}
|
||||
</p>
|
||||
<button
|
||||
@click=${() => (this.activeTab = "browse")}
|
||||
@click=${() => this.setActiveTab("browse")}
|
||||
class="px-6 py-2 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-lg transition-all"
|
||||
>
|
||||
${translateText("clan_modal.browse")}
|
||||
|
||||
+18
-7
@@ -231,13 +231,24 @@ export function getModifierLabels(
|
||||
}
|
||||
|
||||
export function renderDuration(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return "0s";
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
let time = "";
|
||||
if (minutes > 0) time += `${minutes}min `;
|
||||
time += `${seconds}s`;
|
||||
return time.trim();
|
||||
// Floor once so fractional inputs don't leak through to the seconds
|
||||
// component (e.g. `0.5` → `"0.5s"`).
|
||||
const whole = Math.floor(totalSeconds);
|
||||
if (whole <= 0) return `0${translateText("common.duration_second_short")}`;
|
||||
const hours = Math.floor(whole / 3600);
|
||||
const minutes = Math.floor((whole % 3600) / 60);
|
||||
const seconds = whole % 60;
|
||||
// Build largest-first, dropping trailing-zero components so 3600s reads
|
||||
// as "1h" rather than "1h 0min 0s", and 60s as "1min" rather than
|
||||
// "1min 0s". Sub-minute durations still surface seconds.
|
||||
const parts: string[] = [];
|
||||
if (hours > 0)
|
||||
parts.push(`${hours}${translateText("common.duration_hour_short")}`);
|
||||
if (minutes > 0)
|
||||
parts.push(`${minutes}${translateText("common.duration_minute_short")}`);
|
||||
if (seconds > 0 || parts.length === 0)
|
||||
parts.push(`${seconds}${translateText("common.duration_second_short")}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function renderTroops(troops: number): string {
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
type ClanMember,
|
||||
type ClanMemberOrder,
|
||||
type ClanMemberSort,
|
||||
type ClanStats,
|
||||
fetchClanDetail,
|
||||
fetchClanMembers,
|
||||
fetchClanStats,
|
||||
joinClan,
|
||||
leaveClan,
|
||||
} from "../../ClanApi";
|
||||
@@ -20,7 +18,6 @@ import {
|
||||
type ClanRole,
|
||||
defaultOrderForSort,
|
||||
filterMembersBySearch,
|
||||
renderClanWL,
|
||||
renderLoadingSpinner,
|
||||
renderMemberPagination,
|
||||
renderMemberRow,
|
||||
@@ -50,8 +47,8 @@ export class ClanDetailView extends LitElement {
|
||||
members: ClanMember[];
|
||||
membersTotal: number;
|
||||
pendingRequestCount: number;
|
||||
stats: ClanStats | null;
|
||||
} | null = null;
|
||||
@property() detailTab: "overview" | "members" = "overview";
|
||||
|
||||
@property({ type: Object }) cachedClan: ClanInfo | null = null;
|
||||
@state() private selectedClan: ClanInfo | null = null;
|
||||
@@ -63,10 +60,10 @@ export class ClanDetailView extends LitElement {
|
||||
@state() private memberSort: ClanMemberSort = "default";
|
||||
@state() private memberOrder: ClanMemberOrder = "asc";
|
||||
@state() private pendingRequestCount = 0;
|
||||
@state() private clanStats: ClanStats | null = null;
|
||||
@state() private loading = false;
|
||||
@state() private actionPending = false;
|
||||
@state() private allStatsExpanded = false;
|
||||
@state() private membersLoadInFlight = false;
|
||||
private memberSearch = "";
|
||||
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
private asyncGeneration = 0;
|
||||
@@ -85,7 +82,6 @@ export class ClanDetailView extends LitElement {
|
||||
this.members = cache.members;
|
||||
this.membersTotal = cache.membersTotal;
|
||||
this.pendingRequestCount = cache.pendingRequestCount;
|
||||
this.clanStats = cache.stats;
|
||||
this.memberPage = 1;
|
||||
const knownRole = this.myClanRoles.get(this.clanTag);
|
||||
this.myRole = knownRole ?? null;
|
||||
@@ -111,23 +107,22 @@ export class ClanDetailView extends LitElement {
|
||||
this.pendingRequestCount = 0;
|
||||
this.memberSearch = "";
|
||||
|
||||
const isMember = this.myClanRoles.has(this.clanTag);
|
||||
const [detail, membersRes, stats] = await Promise.all([
|
||||
fetchClanDetail(this.clanTag),
|
||||
isMember
|
||||
? fetchClanMembers(
|
||||
this.clanTag,
|
||||
1,
|
||||
this.membersPerPage,
|
||||
this.memberSort,
|
||||
this.memberOrder,
|
||||
)
|
||||
: Promise.resolve(false as const),
|
||||
fetchClanStats(this.clanTag),
|
||||
]);
|
||||
// When the user lands directly on the Members tab (deep link / cached
|
||||
// activeTab), fire both fetches in parallel — otherwise sequencing
|
||||
// adds a full members RTT to the visible loading time. The Overview
|
||||
// tab waits for detail only; willUpdate kicks off members on tab
|
||||
// switch later.
|
||||
const goingToMembers =
|
||||
this.detailTab === "members" && this.myClanRoles.has(this.clanTag);
|
||||
const detailPromise = fetchClanDetail(this.clanTag);
|
||||
if (goingToMembers) {
|
||||
// Floating; loadInitialMembers's own asyncGeneration + tag guards
|
||||
// cancel cleanly if the user navigates away mid-flight.
|
||||
void this.loadInitialMembers();
|
||||
}
|
||||
const detail = await detailPromise;
|
||||
|
||||
if (gen !== this.asyncGeneration) return;
|
||||
this.clanStats = stats || null;
|
||||
this.loading = false;
|
||||
|
||||
if (!detail) {
|
||||
@@ -140,25 +135,13 @@ export class ClanDetailView extends LitElement {
|
||||
|
||||
this.selectedClan = detail;
|
||||
this.memberPage = 1;
|
||||
|
||||
if (membersRes) {
|
||||
this.members = membersRes.results;
|
||||
this.membersTotal = membersRes.total;
|
||||
this.pendingRequestCount = membersRes.pendingRequests ?? 0;
|
||||
const knownRole = this.myClanRoles.get(this.clanTag);
|
||||
if (knownRole) {
|
||||
this.myRole = knownRole;
|
||||
} else {
|
||||
const me = this.myPublicId
|
||||
? membersRes.results.find((m) => m.publicId === this.myPublicId)
|
||||
: null;
|
||||
this.myRole = me ? me.role : null;
|
||||
}
|
||||
} else {
|
||||
if (!goingToMembers) {
|
||||
// Members tab will populate these via loadInitialMembers; the
|
||||
// Overview tab doesn't need them.
|
||||
this.members = [];
|
||||
this.membersTotal = 0;
|
||||
this.myRole = null;
|
||||
}
|
||||
this.myRole = this.myClanRoles.get(this.clanTag) ?? null;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("detail-loaded", {
|
||||
@@ -168,7 +151,6 @@ export class ClanDetailView extends LitElement {
|
||||
members: this.members,
|
||||
membersTotal: this.membersTotal,
|
||||
pendingRequestCount: this.pendingRequestCount,
|
||||
stats: this.clanStats,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
@@ -176,6 +158,61 @@ export class ClanDetailView extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private async loadInitialMembers() {
|
||||
if (this.membersLoadInFlight) return;
|
||||
if (!this.clanTag) return;
|
||||
if (!this.myClanRoles.has(this.clanTag)) return;
|
||||
if (this.members.length > 0) return;
|
||||
// Don't share `asyncGeneration` with loadDetail — these two run
|
||||
// concurrently when the user lands on the Members tab directly, and
|
||||
// bumping the shared counter would cancel the parent. The
|
||||
// `membersLoadInFlight` flag dedupes concurrent invocations and the
|
||||
// `requestedTag` check handles tag navigation.
|
||||
const requestedTag = this.clanTag;
|
||||
this.membersLoadInFlight = true;
|
||||
try {
|
||||
const res = await fetchClanMembers(
|
||||
requestedTag,
|
||||
1,
|
||||
this.membersPerPage,
|
||||
this.memberSort,
|
||||
this.memberOrder,
|
||||
);
|
||||
if (requestedTag !== this.clanTag) return;
|
||||
if (!res) return;
|
||||
this.members = res.results;
|
||||
this.membersTotal = res.total;
|
||||
this.pendingRequestCount = res.pendingRequests ?? 0;
|
||||
this.memberPage = 1;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("members-loaded", {
|
||||
detail: {
|
||||
members: this.members,
|
||||
membersTotal: this.membersTotal,
|
||||
pendingRequestCount: this.pendingRequestCount,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
this.membersLoadInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changed: Map<string, unknown>) {
|
||||
if (
|
||||
(changed.has("detailTab") || changed.has("selectedClan")) &&
|
||||
this.detailTab === "members" &&
|
||||
this.selectedClan &&
|
||||
this.members.length === 0 &&
|
||||
this.myClanRoles.has(this.clanTag)
|
||||
) {
|
||||
this.loadInitialMembers();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMemberPage(page: number) {
|
||||
if (!this.selectedClan) return;
|
||||
const res = await fetchClanMembers(
|
||||
@@ -304,6 +341,33 @@ export class ClanDetailView extends LitElement {
|
||||
(r) => r.tag === clan.tag,
|
||||
);
|
||||
|
||||
if (this.detailTab === "members") {
|
||||
if (!isMember) {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("clan_modal.members_visible_to_members")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// Initial lazy-load: show a spinner instead of an empty members
|
||||
// list + pagination so there's no flash of "no members".
|
||||
if (this.membersLoadInFlight && this.members.length === 0) {
|
||||
return renderLoadingSpinner();
|
||||
}
|
||||
return html`
|
||||
<div class="space-y-6">
|
||||
${canManageRequests && this.pendingRequestCount > 0
|
||||
? this.renderRequestsButton()
|
||||
: ""}
|
||||
${this.renderMembersList()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-5">
|
||||
@@ -325,12 +389,6 @@ export class ClanDetailView extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
|
||||
${this.clanStats ? renderClanWL(this.clanStats) : ""}
|
||||
${canManageRequests && this.pendingRequestCount > 0
|
||||
? this.renderRequestsButton()
|
||||
: ""}
|
||||
${isMember ? this.renderMembersList() : ""}
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
${this.renderActionButtons(
|
||||
isMember,
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
import { html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { GameMapType, GameMode } from "../../../core/game/Game";
|
||||
import {
|
||||
type ClanGame,
|
||||
type ClanGameFilter,
|
||||
fetchClanGames,
|
||||
} from "../../ClanApi";
|
||||
import { ClientEnv } from "../../ClientEnv";
|
||||
import { terrainMapFileLoader } from "../../TerrainMapFileLoader";
|
||||
import { getMapName, renderDuration, translateText } from "../../Utils";
|
||||
import "../CopyButton";
|
||||
import { renderLoadingSpinner, showToast } from "./ClanShared";
|
||||
|
||||
type FilterKey = ClanGameFilter | "all";
|
||||
|
||||
// "All" is filter-only; FFA and Team reuse the type-label keys (same
|
||||
// English strings); HvN and Ranked have shorter filter labels than their
|
||||
// type labels ("Humans vs Nations" / "Ranked 1v1") so keep those split.
|
||||
const FILTER_TABS: { key: FilterKey; labelKey: string }[] = [
|
||||
{ key: "all", labelKey: "clan_modal.history_filter_all" },
|
||||
{ key: "ffa", labelKey: "clan_modal.history_type_ffa" },
|
||||
{ key: "team", labelKey: "clan_modal.history_type_team" },
|
||||
{ key: "hvn", labelKey: "clan_modal.history_filter_hvn" },
|
||||
{ key: "ranked", labelKey: "clan_modal.history_filter_ranked" },
|
||||
];
|
||||
|
||||
// Cache survives a tab switch within the modal: keep the full
|
||||
// accumulated list plus the cursor state so re-entering the tab restores
|
||||
// the scroll position the user had built up.
|
||||
export type ClanGameHistoryCache = {
|
||||
tag: string;
|
||||
filter: FilterKey;
|
||||
games: ClanGame[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
@customElement("clan-game-history-view")
|
||||
export class ClanGameHistoryView extends LitElement {
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property() clanTag = "";
|
||||
@property({ type: Object }) cachedState: ClanGameHistoryCache | null = null;
|
||||
|
||||
@state() private games: ClanGame[] = [];
|
||||
@state() private nextCursor: string | null = null;
|
||||
@state() private loading = false;
|
||||
// Distinct from `loading` because it controls the inline footer spinner
|
||||
// rather than replacing the whole list with a centred spinner.
|
||||
@state() private loadingMore = false;
|
||||
@state() private loadState: "ok" | "failed" | "forbidden" = "ok";
|
||||
@state() private appendFailed = false;
|
||||
@state() private filter: FilterKey = "all";
|
||||
private asyncGeneration = 0;
|
||||
private sentinel: HTMLElement | null = null;
|
||||
private observer: IntersectionObserver | null = null;
|
||||
// Memoise grouping against the current `games` reference so re-renders
|
||||
// triggered by unrelated state (e.g. `loadingMore` flipping) don't
|
||||
// re-walk the accumulated list each time.
|
||||
private groupedFor: ClanGame[] | null = null;
|
||||
private grouped: DayGroup[] = [];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.cachedState && this.cachedState.tag === this.clanTag) {
|
||||
this.games = this.cachedState.games;
|
||||
this.nextCursor = this.cachedState.nextCursor;
|
||||
this.filter = this.cachedState.filter;
|
||||
} else if (this.clanTag) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.teardownObserver();
|
||||
}
|
||||
|
||||
updated() {
|
||||
// The IntersectionObserver target only exists when there's more to
|
||||
// load AND we're not in the middle of a request — wire it up after
|
||||
// each render so it tracks the current sentinel node.
|
||||
this.ensureObserver();
|
||||
}
|
||||
|
||||
// Hard reset on filter change — drop cached games and start fresh from
|
||||
// the newest game.
|
||||
private async reload() {
|
||||
this.games = [];
|
||||
this.nextCursor = null;
|
||||
this.appendFailed = false;
|
||||
await this.load({ append: false });
|
||||
}
|
||||
|
||||
private setFilter(filter: FilterKey) {
|
||||
if (filter === this.filter) return;
|
||||
this.filter = filter;
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private async load({ append }: { append: boolean }) {
|
||||
if (!this.clanTag) return;
|
||||
const gen = ++this.asyncGeneration;
|
||||
if (append) {
|
||||
this.loadingMore = true;
|
||||
this.appendFailed = false;
|
||||
} else {
|
||||
this.loading = true;
|
||||
this.loadState = "ok";
|
||||
this.loadingMore = false;
|
||||
}
|
||||
const filterParam = this.filter === "all" ? undefined : this.filter;
|
||||
// Append uses the saved cursor; a fresh load starts from the newest
|
||||
// game (no cursor).
|
||||
const cursor = append ? (this.nextCursor ?? undefined) : undefined;
|
||||
const res = await fetchClanGames(this.clanTag, {
|
||||
filter: filterParam,
|
||||
cursor,
|
||||
});
|
||||
if (gen !== this.asyncGeneration) return;
|
||||
if (append) this.loadingMore = false;
|
||||
else this.loading = false;
|
||||
if ("error" in res) {
|
||||
if (append) {
|
||||
// Keep the games we already have; just surface a retry footer.
|
||||
this.appendFailed = true;
|
||||
} else {
|
||||
this.loadState = res.error;
|
||||
this.games = [];
|
||||
this.nextCursor = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.games = append ? [...this.games, ...res.results] : res.results;
|
||||
this.nextCursor = res.nextCursor;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<ClanGameHistoryCache>("history-updated", {
|
||||
detail: {
|
||||
tag: this.clanTag,
|
||||
filter: this.filter,
|
||||
games: this.games,
|
||||
nextCursor: this.nextCursor,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private ensureObserver() {
|
||||
const sentinel = this.querySelector<HTMLElement>("[data-scroll-sentinel]");
|
||||
if (sentinel === this.sentinel) return;
|
||||
this.teardownObserver();
|
||||
this.sentinel = sentinel;
|
||||
if (!sentinel) return;
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
if (this.loading || this.loadingMore) continue;
|
||||
if (this.nextCursor === null) continue;
|
||||
if (this.appendFailed) continue;
|
||||
void this.load({ append: true });
|
||||
}
|
||||
});
|
||||
this.observer.observe(sentinel);
|
||||
}
|
||||
|
||||
private teardownObserver() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
this.sentinel = null;
|
||||
}
|
||||
|
||||
private async watchReplay(gameId: string) {
|
||||
try {
|
||||
const encoded = encodeURIComponent(gameId);
|
||||
const url = `/${ClientEnv.workerPath(gameId)}/game/${encoded}`;
|
||||
history.pushState({ join: gameId }, "", url);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("join-changed", { detail: { gameId: encoded } }),
|
||||
);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("close-clan-modal", { bubbles: true, composed: true }),
|
||||
);
|
||||
} catch {
|
||||
showToast(translateText("clan_modal.error_failed"), "red");
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loadState === "forbidden") {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("clan_modal.history_members_only")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<div class="space-y-3">
|
||||
${this.renderFilters()}${this.renderBody()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderFilters(): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex flex-wrap gap-1 p-1 bg-white/5 border border-white/10 rounded-xl"
|
||||
>
|
||||
${FILTER_TABS.map((tab) => {
|
||||
const active = this.filter === tab.key;
|
||||
// "All" gets a full row on mobile (basis-full) and normal sizing
|
||||
// on sm+. The others use basis-20 so "Ranked" stays comfortable
|
||||
// and flex-wrap drops them to a second row when needed.
|
||||
const basis =
|
||||
tab.key === "all" ? "basis-full sm:basis-20" : "basis-20";
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=${active}
|
||||
@click=${() => this.setFilter(tab.key)}
|
||||
class="grow ${basis} px-3 py-1.5 text-xs font-bold uppercase tracking-wider whitespace-nowrap rounded-lg transition-colors ${active
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30"
|
||||
: "text-white/50 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
>
|
||||
${translateText(tab.labelKey)}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBody(): TemplateResult {
|
||||
if (this.loading && this.games.length === 0) {
|
||||
return renderLoadingSpinner();
|
||||
}
|
||||
if (this.loadState === "failed") {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm mb-3">
|
||||
${translateText("clan_modal.history_unavailable")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => this.reload()}
|
||||
class="text-xs font-bold text-white/60 hover:text-white uppercase tracking-wider px-3 py-1.5 rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
${translateText("leaderboard_modal.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (this.games.length === 0) {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("clan_modal.history_empty")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Group consecutive games by their start day so the user gets a sense
|
||||
// of when each batch was played without us having to render N
|
||||
// standalone date pills. Cached against the `games` reference; `load()`
|
||||
// always assigns a fresh array, so identity comparison is safe.
|
||||
if (this.groupedFor !== this.games) {
|
||||
this.grouped = groupGamesByDay(this.games);
|
||||
this.groupedFor = this.games;
|
||||
}
|
||||
const groups = this.grouped;
|
||||
return html`
|
||||
<div class="space-y-5">
|
||||
${groups.map(
|
||||
(group) => html`
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center gap-3 px-1 py-1.5"
|
||||
>
|
||||
<span class="h-px flex-1 bg-white/10"></span>
|
||||
<h3
|
||||
class="text-xs font-bold uppercase tracking-widest text-white/70 whitespace-nowrap"
|
||||
>
|
||||
${formatDayHeader(group.day)}
|
||||
</h3>
|
||||
<span class="h-px flex-1 bg-white/10"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
${group.games.map((game) => this.renderGameRow(game))}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
${this.renderScrollFooter()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderScrollFooter(): TemplateResult {
|
||||
if (this.nextCursor === null) {
|
||||
return html`
|
||||
<div class="text-center text-[11px] text-white/30 py-3 select-none">
|
||||
${translateText("clan_modal.history_end_of_history")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (this.appendFailed) {
|
||||
return html`
|
||||
<div class="text-center py-3">
|
||||
<p class="text-white/40 text-xs mb-2">
|
||||
${translateText("clan_modal.history_load_more_failed")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => this.load({ append: true })}
|
||||
class="text-xs font-bold text-white/60 hover:text-white uppercase tracking-wider px-3 py-1.5 rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
${translateText("leaderboard_modal.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// Sentinel drives auto-load; the spinner is shown adjacent to it (not
|
||||
// *as* it) so the sentinel node identity stays stable across pages —
|
||||
// otherwise every fetch tears down and recreates the IntersectionObserver
|
||||
// (the spinner replacing the sentinel changed the queried node).
|
||||
return html`
|
||||
<div class="py-3">
|
||||
<div data-scroll-sentinel aria-hidden="true" class="h-px"></div>
|
||||
${this.loadingMore ? renderLoadingSpinner() : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGameRow(game: ClanGame): TemplateResult {
|
||||
// getMapData() throws for unknown map values — guard so an unmapped
|
||||
// server response doesn't tank the whole history view.
|
||||
let mapWebpPath: string | null = null;
|
||||
if (game.map) {
|
||||
try {
|
||||
mapWebpPath = terrainMapFileLoader.getMapData(
|
||||
game.map as GameMapType,
|
||||
).webpPath;
|
||||
} catch {
|
||||
mapWebpPath = null;
|
||||
}
|
||||
}
|
||||
const mapDisplayName = game.map ? (getMapName(game.map) ?? game.map) : null;
|
||||
// Partition once per row so renderResultBadge + renderPlayerLists
|
||||
// don't each re-walk clanPlayers (matters in 50v50 lobbies).
|
||||
const winners: ClanGame["clanPlayers"] = [];
|
||||
const losers: ClanGame["clanPlayers"] = [];
|
||||
for (const p of game.clanPlayers) {
|
||||
(p.won ? winners : losers).push(p);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="bg-white/5 border border-white/10 rounded-xl overflow-hidden">
|
||||
${mapWebpPath
|
||||
? html`<div
|
||||
class="relative w-full aspect-[3/1] overflow-hidden bg-surface"
|
||||
>
|
||||
<img
|
||||
src=${mapWebpPath}
|
||||
alt=${mapDisplayName ?? ""}
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"
|
||||
></div>
|
||||
${mapDisplayName
|
||||
? html`<div
|
||||
class="absolute bottom-2 left-3 text-xs font-bold text-white uppercase tracking-wider drop-shadow"
|
||||
>
|
||||
${mapDisplayName}
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="absolute top-2 right-2">
|
||||
${this.renderResultBadge(game, winners)}
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-2 right-2 text-xs font-medium text-white bg-black/60 backdrop-blur-sm px-2 py-1 rounded-md whitespace-nowrap"
|
||||
>
|
||||
${formatAbsoluteTime(game.start)}
|
||||
</div>
|
||||
</div>`
|
||||
: ""}
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 px-4 py-3 border-b border-white/5"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-white/40"
|
||||
>${translateText("clan_modal.history_game_id")}:</span
|
||||
>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${game.gameId}
|
||||
.displayText=${game.gameId}
|
||||
.showVisibilityToggle=${false}
|
||||
></copy-button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => this.watchReplay(game.gameId)}
|
||||
class="shrink-0 px-3 py-1.5 text-xs font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-lg transition-all"
|
||||
>
|
||||
${translateText("clan_modal.history_watch_replay")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="px-4 py-3 grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 justify-items-center text-center"
|
||||
>
|
||||
${this.renderField(
|
||||
translateText("clan_modal.history_game_type"),
|
||||
this.formatGameType(game),
|
||||
)}
|
||||
${mapWebpPath
|
||||
? ""
|
||||
: this.renderField(
|
||||
translateText("clan_modal.history_map"),
|
||||
mapDisplayName ?? "—",
|
||||
)}
|
||||
${this.renderPlayersField(game)}
|
||||
${this.renderField(
|
||||
translateText("clan_modal.history_duration"),
|
||||
renderDuration(game.durationSeconds),
|
||||
)}
|
||||
</div>
|
||||
${this.renderPlayerLists(game, winners, losers)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderField(label: string, value: string): TemplateResult {
|
||||
return html`
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-0.5"
|
||||
>
|
||||
${label}
|
||||
</div>
|
||||
<div class="text-sm text-white truncate" title=${value}>${value}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// For FFA / Ranked 1v1 with multiple clan-mates in the same lobby,
|
||||
// calling the whole game a "Victory" because one of 20 won is
|
||||
// misleading — 19 lost. The server now stamps `won` per clan player
|
||||
// so we count exactly. Team/HvN games still surface Victory/Defeat
|
||||
// when the clan plays as a unit (everyone on the winning team won).
|
||||
private renderResultBadge(
|
||||
game: ClanGame,
|
||||
winners: ClanGame["clanPlayers"],
|
||||
): TemplateResult {
|
||||
const result = game.result;
|
||||
if (!result) return html``;
|
||||
|
||||
const clanCount = game.clanPlayers.length;
|
||||
const winCount = winners.length;
|
||||
const isIndividual =
|
||||
isFfa(game) ||
|
||||
(game.rankedType !== undefined && game.rankedType !== "unranked");
|
||||
const isPartial =
|
||||
isIndividual && clanCount > 1 && winCount > 0 && winCount < clanCount;
|
||||
|
||||
let label: string;
|
||||
let tint: string;
|
||||
if (isPartial) {
|
||||
label = translateText("clan_modal.history_result_partial", {
|
||||
wins: winCount,
|
||||
total: clanCount,
|
||||
});
|
||||
tint = "text-white bg-amber-500 border-amber-400";
|
||||
} else if (result === "victory") {
|
||||
label = translateText("clan_modal.history_result_victory");
|
||||
tint = "text-white bg-green-600 border-green-500";
|
||||
} else {
|
||||
label = translateText("clan_modal.history_result_defeat");
|
||||
tint = "text-white bg-red-600 border-red-500";
|
||||
}
|
||||
return html`<span
|
||||
class="text-xs font-bold uppercase tracking-wider px-2.5 py-1 rounded-full border shadow-lg ${tint}"
|
||||
>${label}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
// Split the clan roster into winners and non-winners so the user can
|
||||
// tell at a glance which clan-mates actually won the match — a single
|
||||
// mixed list with crowns was hard to scan, especially in 50v50 lobbies.
|
||||
private renderPlayerLists(
|
||||
game: ClanGame,
|
||||
winners: ClanGame["clanPlayers"],
|
||||
losers: ClanGame["clanPlayers"],
|
||||
): TemplateResult | string {
|
||||
if (game.clanPlayers.length === 0) return "";
|
||||
return html`
|
||||
${winners.length > 0
|
||||
? this.renderPlayerSection(
|
||||
translateText("clan_modal.history_clan_winners"),
|
||||
winners,
|
||||
"text-green-400",
|
||||
)
|
||||
: ""}
|
||||
${losers.length > 0
|
||||
? this.renderPlayerSection(
|
||||
translateText("clan_modal.history_clan_members"),
|
||||
losers,
|
||||
"text-white/40",
|
||||
)
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlayerSection(
|
||||
label: string,
|
||||
players: ClanGame["clanPlayers"],
|
||||
labelClass: string,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="px-4 py-2 border-t border-white/5 text-xs text-white/60 flex flex-wrap items-center gap-x-1 gap-y-1"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider mr-1 ${labelClass}"
|
||||
>${label}:</span
|
||||
>
|
||||
${players.map(
|
||||
(p) => html`
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${p.publicId}
|
||||
.displayText=${p.username ?? p.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Ranked games cap clan participation at a single player, so
|
||||
// "1 / N total" is noise — just show the total. FFA can carry
|
||||
// multiple clan members (renderResultBadge already handles partial
|
||||
// wins via clanCount > 1), so it keeps the clan-vs-total breakdown.
|
||||
// Team/HvN keep it too. Historical rows may carry a null
|
||||
// totalPlayers (games.num_players is nullable on the schema); render
|
||||
// "—" rather than "null".
|
||||
private renderPlayersField(game: ClanGame): TemplateResult {
|
||||
const isSingleClanSlot =
|
||||
game.rankedType !== undefined && game.rankedType !== "unranked";
|
||||
const total = game.totalPlayers ?? null;
|
||||
if (isSingleClanSlot) {
|
||||
return this.renderField(
|
||||
translateText("clan_modal.history_players"),
|
||||
total === null ? "—" : `${total}`,
|
||||
);
|
||||
}
|
||||
return this.renderField(
|
||||
translateText("clan_modal.history_clan_players"),
|
||||
total === null
|
||||
? `${game.clanPlayers.length}`
|
||||
: translateText("clan_modal.history_clan_players_value", {
|
||||
clanCount: game.clanPlayers.length,
|
||||
total,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// FFA / Duos / 7 Teams / Humans vs Nations / Ranked 1v1 — derived from
|
||||
// the same fields the bucket filter uses, so the label always agrees
|
||||
// with the active tab.
|
||||
private formatGameType(game: ClanGame): string {
|
||||
if (game.rankedType && game.rankedType !== "unranked") {
|
||||
return translateText("clan_modal.history_type_ranked", {
|
||||
ranked: game.rankedType,
|
||||
});
|
||||
}
|
||||
if (isFfa(game)) {
|
||||
return translateText("clan_modal.history_type_ffa");
|
||||
}
|
||||
const pt = game.playerTeams;
|
||||
if (pt === "Humans Vs Nations") {
|
||||
return translateText("clan_modal.history_type_hvn");
|
||||
}
|
||||
if (pt === "Duos" || pt === "Trios" || pt === "Quads") {
|
||||
return translateText(`clan_modal.history_type_${pt.toLowerCase()}`);
|
||||
}
|
||||
if (pt && /^\d+$/.test(pt)) {
|
||||
return translateText("clan_modal.history_type_n_teams", {
|
||||
count: Number(pt),
|
||||
});
|
||||
}
|
||||
return translateText("clan_modal.history_type_team");
|
||||
}
|
||||
}
|
||||
|
||||
// FFA is "no team grouping". Match the server's `GameMode.FFA` enum
|
||||
// literal first, but fall back to absent `playerTeams` so a row that
|
||||
// arrives without the mode field (older row, server bug) still labels
|
||||
// as FFA instead of silently degrading to "Team" — which would
|
||||
// disagree with the FFA filter bucket that already routed it here.
|
||||
function isFfa(game: ClanGame): boolean {
|
||||
if (game.mode === GameMode.FFA) return true;
|
||||
if (
|
||||
game.mode === undefined &&
|
||||
(game.playerTeams === null || game.playerTeams === undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (!Number.isFinite(date.getTime())) return iso;
|
||||
const now = new Date();
|
||||
const time = date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
if (sameDay) {
|
||||
return translateText("clan_modal.history_today_at", { time });
|
||||
}
|
||||
return `${date.toLocaleDateString()} ${time}`;
|
||||
}
|
||||
|
||||
type DayGroup = { day: string; games: ClanGame[] };
|
||||
|
||||
// Groups games by local-day key while preserving server order. Server
|
||||
// ordering is already newest-first, so within a group we just keep the
|
||||
// arrival order.
|
||||
function groupGamesByDay(games: ClanGame[]): DayGroup[] {
|
||||
const groups: DayGroup[] = [];
|
||||
for (const g of games) {
|
||||
const day = dayKey(g.start);
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.day === day) {
|
||||
last.games.push(g);
|
||||
} else {
|
||||
groups.push({ day, games: [g] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function dayKey(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (!Number.isFinite(d.getTime())) return iso;
|
||||
// Use local-time YYYY-MM-DD so headers line up with the user's clock,
|
||||
// not UTC midnight (which would split late-night games into a "next
|
||||
// day" group for most timezones).
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// Indexed by Date.getMonth() (0–11). Kept as a const list rather than
|
||||
// a switch so the translation pipeline picks up every key from a single
|
||||
// place.
|
||||
const MONTH_KEYS = [
|
||||
"common.month_jan",
|
||||
"common.month_feb",
|
||||
"common.month_mar",
|
||||
"common.month_apr",
|
||||
"common.month_may",
|
||||
"common.month_jun",
|
||||
"common.month_jul",
|
||||
"common.month_aug",
|
||||
"common.month_sep",
|
||||
"common.month_oct",
|
||||
"common.month_nov",
|
||||
"common.month_dec",
|
||||
] as const;
|
||||
|
||||
function formatDayHeader(day: string): string {
|
||||
const d = new Date(`${day}T00:00:00`);
|
||||
if (!Number.isFinite(d.getTime())) return day;
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const diffDays = Math.round(
|
||||
(today.getTime() - dayStart.getTime()) / (24 * 60 * 60 * 1000),
|
||||
);
|
||||
if (diffDays === 0) return translateText("clan_modal.history_today");
|
||||
if (diffDays === 1) return translateText("clan_modal.history_yesterday");
|
||||
// "17 May 2026" — weekday dropped (no translation coverage) and the
|
||||
// month rendered through our own translation keys instead of
|
||||
// toLocaleDateString so other locales can swap it cleanly.
|
||||
const month = translateText(MONTH_KEYS[d.getMonth()]);
|
||||
return `${d.getDate()} ${month} ${d.getFullYear()}`;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
ClanMemberOrder,
|
||||
ClanMemberSort,
|
||||
ClanMemberStats,
|
||||
ClanStats,
|
||||
} from "../../ClanApi";
|
||||
import { showToast, translateText } from "../../Utils";
|
||||
import "./ClanStatsBreakdown";
|
||||
@@ -82,18 +81,6 @@ export function renderStat(label: string, value: string): TemplateResult {
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderClanWL(stats: ClanStats): TemplateResult | string {
|
||||
if (stats.games === 0) return "";
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
|
||||
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
|
||||
${translateText("clan_modal.statistics")}
|
||||
</h3>
|
||||
<clan-stats-breakdown .stats=${stats.stats}></clan-stats-breakdown>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPaginationButtons(
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
|
||||
+44
-15
@@ -137,19 +137,48 @@ export const JoinClanResponseSchema = z.object({
|
||||
});
|
||||
export type JoinClanResponse = z.infer<typeof JoinClanResponseSchema>;
|
||||
|
||||
export const ClanStatsSchema = z.object({
|
||||
clanTag: RequiredClanTagSchema,
|
||||
games: z.number(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
stats: ClanMemberStatsSchema,
|
||||
teamTypeWL: z.record(
|
||||
z.string(),
|
||||
z.object({ wl: z.tuple([z.number(), z.number()]) }),
|
||||
),
|
||||
teamCountWL: z.record(
|
||||
z.string(),
|
||||
z.object({ wl: z.tuple([z.number(), z.number()]) }),
|
||||
),
|
||||
export const ClanGamePlayerSchema = z.object({
|
||||
publicId: z.string(),
|
||||
username: z.string(),
|
||||
won: z.boolean(),
|
||||
});
|
||||
export type ClanStats = z.infer<typeof ClanStatsSchema>;
|
||||
export type ClanGamePlayer = z.infer<typeof ClanGamePlayerSchema>;
|
||||
|
||||
// "incomplete" covers games with no recorded winner.
|
||||
// The server stamps this when winnerType IS NULL,
|
||||
// so we have to accept it on the wire even if the UI collapses it back
|
||||
// into the defeat-styled badge.
|
||||
export const ClanGameResultSchema = z.enum(["victory", "defeat", "incomplete"]);
|
||||
export type ClanGameResult = z.infer<typeof ClanGameResultSchema>;
|
||||
|
||||
export const ClanGameFilters = ["ffa", "team", "hvn", "ranked"] as const;
|
||||
export const ClanGameFilterSchema = z.enum(ClanGameFilters);
|
||||
export type ClanGameFilter = z.infer<typeof ClanGameFilterSchema>;
|
||||
|
||||
export const ClanGameSchema = z.object({
|
||||
gameId: z.string(),
|
||||
start: z.iso.datetime(),
|
||||
durationSeconds: z.number().int().nonnegative(),
|
||||
map: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
// playerTeams is `null` (not absent) for FFA / non-team games — use
|
||||
// `.nullish()` so the wire `null` doesn't fail the parse.
|
||||
playerTeams: z.string().nullish(),
|
||||
rankedType: z.string().optional(),
|
||||
result: ClanGameResultSchema.optional(),
|
||||
// Mirrors games.num_players nullability — historical rows may not
|
||||
// carry a value. Use `.nullish()` so wire `null` parses cleanly.
|
||||
totalPlayers: z.number().int().nonnegative().nullish(),
|
||||
clanPlayers: ClanGamePlayerSchema.array(),
|
||||
});
|
||||
export type ClanGame = z.infer<typeof ClanGameSchema>;
|
||||
|
||||
export const ClanGamesResponseSchema = z.object({
|
||||
results: ClanGameSchema.array(),
|
||||
// Opaque continuation token. Round-trip verbatim as the `cursor` query
|
||||
// parameter to fetch the next page; never construct or parse it.
|
||||
// `null` means the server has no more rows to serve. Page size is
|
||||
// fixed server-side, so the client never sends a limit.
|
||||
nextCursor: z.string().nullable(),
|
||||
});
|
||||
export type ClanGamesResponse = z.infer<typeof ClanGamesResponseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user