Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,11 @@
"pending_requests_count": "{count, plural, one {# pending request} other {# pending requests}}",
"per_page": "Per page",
"promote": "Promote",
"recent_24h_empty": "No games in the past 24 hours.",
"recent_24h_games": "{count, plural, one {# game} other {# games}}",
"recent_24h_note": "Public Team games only (unranked, excluding Humans vs Nations).",
"recent_24h_ratio_tooltip": "Win score divided by loss score",
"recent_24h_title": "Past 24 Hours",
"request_approved": "Request approved!",
"request_denied": "Request denied.",
"request_invite": "Request Invite",
Expand Down
38 changes: 38 additions & 0 deletions src/client/ClanApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ import {
ClanMembersResponseSchema,
type ClanRequestsResponse,
ClanRequestsResponseSchema,
type ClanWindowStatsResponse,
ClanWindowStatsResponseSchema,
DiscordInviteResponseSchema,
JoinClanResponseSchema,
} from "../core/ClanApiSchemas";
import { getApiBase, getUserMe } from "./Api";
import { getAuthHeader } from "./Auth";

const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000;
const CLAN_RECENT_STATS_FETCH_TIMEOUT_MS = 5000;
const CLAN_RECENT_STATS_WINDOW_MS = 24 * 60 * 60 * 1000;
export type {
ClanBan,
ClanBansResponse,
Expand All @@ -39,6 +43,8 @@ export type {
ClanMemberStats,
ClanMemberWL,
ClanRequestsResponse,
ClanWindowStats,
ClanWindowStatsResponse,
} from "../core/ClanApiSchemas";

async function clanFetch(
Expand Down Expand Up @@ -148,6 +154,38 @@ export async function fetchClanExists(tag: string): Promise<boolean | null> {
}
}

// Rolling 24-hour aggregate for one clan from the public (no-auth) endpoint.
// The API rejects windows longer than one day, so `start` is exactly 24h
// before `end`. Returns false on any failure — the overview card is
// supplementary and simply stays hidden.
export async function fetchClanRecentStats(
tag: string,
): Promise<ClanWindowStatsResponse | false> {
try {
const end = new Date();
const start = new Date(end.getTime() - CLAN_RECENT_STATS_WINDOW_MS);
const params = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString(),
});
// Uppercased to the canonical form so it matches the server's route.
const path = `/public/clan/${encodeURIComponent(tag.toUpperCase())}?${params}`;
const res = await fetch(`${getApiBase()}${path}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(CLAN_RECENT_STATS_FETCH_TIMEOUT_MS),
});
if (!res.ok) return false;
const parsed = ClanWindowStatsResponseSchema.safeParse(await res.json());
if (!parsed.success) {
console.warn("fetchClanRecentStats: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch {
return false;
}
}

/**
* Client-side mirror of the server's clan-tag ownership rule (resolveClanTag in
* Privilege.ts), for instant inline feedback. Returns the tag to submit (null
Expand Down
133 changes: 131 additions & 2 deletions src/client/components/clan/ClanDetailView.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { html, LitElement } from "lit";
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import {
Expand All @@ -7,8 +7,10 @@ import {
type ClanMember,
type ClanMemberOrder,
type ClanMemberSort,
type ClanWindowStats,
fetchClanDetail,
fetchClanMembers,
fetchClanRecentStats,
fetchDiscordInvite,
joinClan,
leaveClan,
Expand All @@ -26,6 +28,7 @@ import {
renderMemberSearchInput,
renderMemberSortControl,
renderStat,
renderWLBarRow,
showToast,
} from "./ClanShared";
import { ClanStatsBreakdown } from "./ClanStatsBreakdown";
Expand Down Expand Up @@ -55,6 +58,8 @@ export class ClanDetailView extends LitElement {
@property({ type: Object }) cachedClan: ClanInfo | null = null;
@state() private selectedClan: ClanInfo | null = null;
@state() private discordMeta: ClanDiscord | null = null;
@state() private recentStats: ClanWindowStats | null = null;
@state() private recentStatsLoading = false;
@state() private myRole: ClanRole | null = null;
@state() private members: ClanMember[] = [];
@state() private membersTotal = 0;
Expand Down Expand Up @@ -97,6 +102,9 @@ export class ClanDetailView extends LitElement {
this.asyncGeneration,
);
}
// Deliberately not part of `cachedDetail` — a rolling 24h window goes
// stale, so it is refetched on every mount (like the Discord metadata).
void this.loadRecentStats(this.clanTag, this.asyncGeneration);
}

// Fetches live Discord invite metadata (server name, icon, counts) for the
Expand All @@ -108,6 +116,18 @@ export class ClanDetailView extends LitElement {
this.discordMeta = meta;
}

// Fetches the clan's rolling 24-hour aggregate for the Overview card from
// the public endpoint. Floating; guarded by asyncGeneration + tag like
// loadDiscordMeta. A failure leaves `recentStats` null, which hides the card.
private async loadRecentStats(tag: string, gen: number) {
this.recentStats = null;
this.recentStatsLoading = true;
const res = await fetchClanRecentStats(tag);
if (gen !== this.asyncGeneration || this.clanTag !== tag) return;
this.recentStatsLoading = false;
this.recentStats = res === false ? null : res.clan;
}

disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberLoadSeq++;
Expand All @@ -129,6 +149,9 @@ export class ClanDetailView extends LitElement {
this.myRole = null;
this.pendingRequestCount = 0;
this.memberSearch = "";
// Independent of the clan detail fetch, so start it in parallel rather
// than adding its RTT to the visible loading time.
void this.loadRecentStats(this.clanTag, gen);

// When the user lands directly on the Members tab (deep link / cached
// activeTab), fire both fetches in parallel — otherwise sequencing
Expand Down Expand Up @@ -436,6 +459,7 @@ export class ClanDetailView extends LitElement {
<div class="grid grid-cols-2 gap-4">
${this.renderStatTiles(clan)}
</div>
${this.renderRecentStatsCard()}
</div>
<div class="sm:col-span-2">
${this.renderDiscordCard(clan.discordUrl)}
Expand All @@ -450,7 +474,7 @@ export class ClanDetailView extends LitElement {
<div class="space-y-6">
${this.renderDescriptionCard(clan)}
<div class="grid grid-cols-2 gap-3">${this.renderStatTiles(clan)}</div>
${actions}
${this.renderRecentStatsCard()} ${actions}
</div>
`;
}
Expand Down Expand Up @@ -606,6 +630,111 @@ export class ClanDetailView extends LitElement {
`;
}

// Rolling 24-hour activity for the Overview tab. The card is omitted (rather
// than showing an error) when the public endpoint is unavailable — it's
// supplementary, and the rest of the overview stands on its own.
private renderRecentStatsCard(): TemplateResult | string {
if (this.recentStatsLoading) {
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-5">
<div class="animate-pulse space-y-3">
<div class="h-3 w-32 bg-white/10 rounded"></div>
<div class="h-5 bg-white/10 rounded-md"></div>
<div class="h-10 bg-white/10 rounded-lg"></div>
</div>
</div>
`;
}
const stats = this.recentStats;
if (stats === null) return "";

return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
<div class="flex items-center justify-between gap-2">
<h3
class="text-sm font-bold text-white/60 uppercase tracking-wider"
title=${translateText("clan_modal.recent_24h_note")}
>
${translateText("clan_modal.recent_24h_title")}
</h3>
${stats.games > 0
? html`<span
class="text-[10px] font-bold text-white/40 uppercase tracking-wider tabular-nums shrink-0"
>
${translateText("clan_modal.recent_24h_games", {
count: stats.games,
})}
</span>`
: ""}
</div>
${stats.games === 0
? html`<p class="text-white/40 text-sm">
${translateText("clan_modal.recent_24h_empty")}
</p>`
: html`
${renderWLBarRow(
translateText("clan_modal.stats_total"),
stats.wins,
stats.losses,
)}
<div class="grid grid-cols-3 gap-2">
${this.renderRecentMetric(
translateText("leaderboard_modal.win_score"),
stats.weightedWins.toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
translateText("leaderboard_modal.win_score_tooltip"),
"text-green-400/90",
)}
${this.renderRecentMetric(
translateText("leaderboard_modal.loss_score"),
stats.weightedLosses.toLocaleString(undefined, {
maximumFractionDigits: 1,
}),
translateText("leaderboard_modal.loss_score_tooltip"),
"text-red-400/90",
)}
${this.renderRecentMetric(
translateText("leaderboard_modal.ratio"),
stats.weightedWLRatio.toLocaleString(undefined, {
maximumFractionDigits: 2,
}),
translateText("clan_modal.recent_24h_ratio_tooltip"),
stats.weightedWLRatio >= 1
? "text-green-400"
: "text-red-400",
)}
</div>
`}
</div>
`;
}

private renderRecentMetric(
label: string,
value: string,
tooltip: string,
valueClass: string,
): TemplateResult {
return html`
<div
class="bg-white/5 rounded-lg border border-white/10 px-2 py-2 text-center"
title=${tooltip}
>
<!-- Wraps rather than truncates: this card also renders in the narrow
left column of the Discord two-column layout. -->
<div
class="text-[10px] font-bold text-white/40 uppercase tracking-wider leading-tight mb-0.5"
>
${label}
</div>
<div class="font-mono font-bold text-sm tabular-nums ${valueClass}">
${value}
</div>
</div>
`;
}

// Renders immediately from the stored URL (placeholder name + working join
// button) and fills in name/icon/counts when the Discord lookup resolves.
private renderDiscordCard(url: string) {
Expand Down
30 changes: 30 additions & 0 deletions src/core/ClanApiSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@ export type ClanLeaderboardResponse = z.infer<
typeof ClanLeaderboardResponseSchema
>;

// Aggregate for one clan over a bounded time window, as served by the public
// (unauthenticated) GET /public/clan/:tag?start=&end= endpoint. The window is
// capped at one day server-side, so this is what the clan overview's "past 24
// hours" card reads. Only public Team games (unranked, excluding HvN) are
// counted — the same bucket the clan leaderboard uses. The per-team-type and
// per-team-count breakdowns the endpoint also returns are intentionally not
// modelled here; Zod strips them.
export const ClanWindowStatsSchema = z.object({
clanTag: RequiredClanTagSchema,
games: z.number(),
// Sum of participating clan members across those games, so a single game
// with four members on the roster counts four sessions.
playerSessions: z.number(),
wins: z.number(),
losses: z.number(),
weightedWins: z.number(),
weightedLosses: z.number(),
weightedWLRatio: z.number(),
});
export type ClanWindowStats = z.infer<typeof ClanWindowStatsSchema>;

export const ClanWindowStatsResponseSchema = z.object({
start: z.iso.datetime(),
end: z.iso.datetime(),
clan: ClanWindowStatsSchema,
});
export type ClanWindowStatsResponse = z.infer<
typeof ClanWindowStatsResponseSchema
>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const ClanInfoSchema = z.object({
name: z.string().max(35),
tag: RequiredClanTagSchema,
Expand Down
3 changes: 3 additions & 0 deletions tests/client/clan/ClanModalTestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export function clanApiMockFactory() {
// ClanDetailView calls this when a clan has a discordUrl; mock the degraded
// plain-link result so view tests never reach the real Discord network.
fetchDiscordInvite: vi.fn(async (url: string) => ({ url, valid: true })),
// The Overview tab's rolling 24h card. Default to the unavailable result
// (card hidden) so existing view tests keep their previous markup.
fetchClanRecentStats: vi.fn(async () => false),
};
}

Expand Down
Loading
Loading