┌─ TS ───────────────────────────────────────────────────────────────────────┐
│ // Common utility functions │
│ import { DUNGEON_MAP, dungeonNameToSlug } from "./wow-constants.js"; │
│ │
│ // Time formatting utilities │
│ export function formatDuration(seconds: number): string { │
│ const minutes = Math.floor(seconds / 60); │
│ const remainingSeconds = seconds % 60; │
│ return minutes > 0 │
│ ? `${minutes}m ${remainingSeconds}s` │
│ : `${remainingSeconds}s`; │
│ } │
│ │
│ export function formatDurationMMSS(milliseconds: number): string { │
│ return `${Math.floor(milliseconds / 60000)}:${String(Math.floor((milliseconds % │
│ 60000) / 1000)).padStart(2, "0")}`; │
│ } │
│ │
│ // Alias for clarity when working with milliseconds │
│ export const formatDurationFromMs = formatDurationMMSS; │
│ │
│ export function formatTimestamp(timestamp: number): string { │
│ return new Date(timestamp).toLocaleDateString("en-US", { │
│ month: "short", │
│ day: "numeric", │
│ hour: "2-digit", │
│ minute: "2-digit", │
│ }); │
│ } │
│ │
│ // compact "Mar 27"; formatTimestamp's time-of-day eats space in dense layouts │
│ export function formatShortDate(timestamp: number): string { │
│ return new Date(timestamp).toLocaleDateString("en-US", { │
│ month: "short", │
│ day: "numeric", │
│ }); │
│ } │
│ │
│ export function formatSimulationDate(timestamp: string | number): string { │
│ return new Date(timestamp).toLocaleString("en-US", { │
│ year: "numeric", │
│ month: "long", │
│ day: "numeric", │
│ hour: "2-digit", │
│ minute: "2-digit", │
│ timeZoneName: "short", │
│ }); │
│ } │
│ │
│ export function formatRaidBuffs( │
│ buffs: Record<string, boolean | number>, │
│ ): string { │
│ const MINOR_WORDS = new Set([ │
│ "of", │
│ "the", │
│ "and", │
│ "to", │
│ "for", │
│ "a", │
│ "an", │
│ "in", │
│ "on", │
│ "with", │
│ "vs", │
│ ]); │
│ │
│ const normalize = (key: string): string => { │
│ if (!key) return ""; │
│ let s = String(key); │
│ // Drop trailing Count (e.g., skullBannerCount -> skullBanner) │
│ s = s.replace(/Count$/i, ""); │
│ // Replace underscores with spaces and split camelCase │
│ s = s.replace(/_/g, " "); │
│ s = s.replace(/([a-z])([A-Z])/g, "$1 $2"); │
│ // Collapse whitespace │
│ s = s.replace(/\s+/g, " ").trim().toLowerCase(); │
│ // Title-case while keeping minor words lowercase │
│ const words = s.split(" "); │
│ const out = words.map((w, i) => { │
│ if (i > 0 && MINOR_WORDS.has(w)) return w; │
│ return w.charAt(0).toUpperCase() + w.slice(1); │
│ }); │
│ return out.join(" "); │
│ }; │
│ │
│ const activeBuffs = Object.entries(buffs) │
│ .filter(([_, value]) => value !== false && value !== 0) │
│ .map(([key, value]) => { │
│ const name = normalize(key); │
│ return typeof value === "number" && value > 1 │
│ ? `${name} (${value})` │
│ : name; │
│ }); │
│ return activeBuffs.join(", "); │
│ } │
│ │
│ export function formatRace(race: string): string { │
│ if (!race) return "Unknown"; │
│ let s = String(race).trim(); │
│ // Strip common enum prefixes like "RaceOrc" -> "Orc" │
│ if (/^Race[A-Z]/.test(s)) s = s.replace(/^Race/, ""); │
│ // Replace underscores with spaces and split CamelCase into words │
│ s = s.replace(/_/g, " "); │
│ s = s.replace(/([a-z])([A-Z])/g, "$1 $2"); │
│ // Normalize extra whitespace │
│ s = s.replace(/\s+/g, " ").trim(); │
│ // Title case each word │
│ s = s.toLowerCase().replace(/\b\w/g, (l) => l.toUpperCase()); │
│ return s; │
│ } │
│ │
│ // General string helpers │
│ export function toTitleCase(input: string): string { │
│ if (!input) return ""; │
│ const s = String(input).replace(/[_-]+/g, " ").trim().toLowerCase(); │
│ return s.replace(/\b\w/g, (l) => l.toUpperCase()); │
│ } │
│ │
│ export function slugifyLabel(input: string): string { │
│ return String(input || "") │
│ .trim() │
│ .toLowerCase() │
│ .replace(/\s+/g, "_"); │
│ } │
│ │
│ // DOM utilities │
│ export function createElement( │
│ tag: string, │
│ className?: string, │
│ textContent?: string, │
│ ): HTMLElement { │
│ const element = document.createElement(tag); │
│ if (className) element.className = className; │
│ if (textContent) element.textContent = textContent; │
│ return element; │
│ } │
│ │
│ export function toggleElementVisibility( │
│ element: HTMLElement, │
│ show: boolean, │
│ ): void { │
│ element.style.display = show ? "block" : "none"; │
│ } │
│ │
│ // URL utilities │
│ export function updateBrowserURL(url: string): void { │
│ window.history.pushState({}, "", url); │
│ } │
│ │
│ export function getURLSearchParams(): URLSearchParams { │
│ return new URLSearchParams(window.location.search); │
│ } │
│ │
│ export function buildLeaderboardURL( │
│ region: string, │
│ realm: string, │
│ dungeonSlug: string, │
│ page?: number, │
│ opts?: { season?: number; basePath?: string }, │
│ ): string { │
│ const season = Number(opts?.season ?? 1); │
│ const basePath = opts?.basePath ?? "challenge-mode"; │
│ const seasonSegment = season > 0 ? `season${season}` : "season1"; │
│ │
│ const normalizedRegion = region || "global"; │
│ const normalizedRealm = realm && realm !== "" ? realm : "all"; │
│ │
│ let baseURL: string; │
│ │
│ if (normalizedRegion === "global") { │
│ baseURL = `/${basePath}/${seasonSegment}/global/${dungeonSlug}`; │
│ } else { │
│ const realmSlug = normalizedRealm || "all"; │
│ baseURL = `/${basePath}/${seasonSegment}/${normalizedRegion}/${realmSlug}/${du │
│ ngeonSlug}`; │
│ } │
│ │
│ if (page && page > 1) { │
│ baseURL += `?page=${page}`; │
│ } │
│ │
│ return baseURL; │
│ } │
│ │
│ export function buildPlayerProfileURL( │
│ region: string, │
│ realmSlug: string, │
│ playerName: string, │
│ ): string { │
│ return `/player/${region}/${realmSlug}/${playerName.toLowerCase()}`; │
│ } │
│ │
│ // Build Players tab URL path from filters │
│ export function buildPlayersLeaderboardURL( │
│ scope: "global" | "regional" | "realm", │
│ opts?: { │
│ region?: string; │
│ realmSlug?: string; │
│ classKey?: string; │
│ page?: number; │
│ }, │
│ ): string { │
│ const cls = (opts?.classKey || "").toLowerCase(); │
│ const reg = (opts?.region || "").toLowerCase(); │
│ const realm = (opts?.realmSlug || "").toLowerCase(); │
│ let path = "/challenge-mode/players"; │
│ if (scope === "global") { │
│ if (cls) path += `/global/${cls}`; │
│ else path += `/global`; │
│ } else if (scope === "regional") { │
│ if (reg) path += `/${reg}`; │
│ else path += `/us`; │
│ if (cls) path += `/${cls}`; │
│ } else if (scope === "realm") { │
│ if (reg) path += `/${reg}`; │
│ if (realm) path += `/${realm}`; │
│ if (cls) path += `/${cls}`; │
│ } │
│ const page = opts?.page || 1; │
│ if (page > 1) path += `?page=${page}`; │
│ return path; │
│ } │
│ │
│ // Form utilities │
│ export function getFormValue(elementId: string): string { │
│ const element = document.getElementById(elementId) as │
│ | HTMLInputElement │
│ | HTMLSelectElement; │
│ return element?.value || ""; │
│ } │
│ │
│ export function setFormValue(elementId: string, value: string): void { │
│ const element = document.getElementById(elementId) as │
│ | HTMLInputElement │
│ | HTMLSelectElement; │
│ if (element) element.value = value; │
│ } │
│ │
│ // Validation utilities │
│ export function validateRequiredFields( │
│ ...values: (string | undefined)[] │
│ ): boolean { │
│ return values.every((value) => value && value.trim().length > 0); │
│ } │
│ │
│ // Error handling utilities │
│ export function handleAPIError(error: Error, context: string): void { │
│ console.error(`${context} error:`, error); │
│ │
│ // Could extend this to show user-friendly error messages │
│ const errorMessage = error.message.includes("404") │
│ ? "Data not found" │
│ : error.message.includes("500") │
│ ? "Server error - please try again later" │
│ : "An error occurred loading data"; │
│ │
│ console.warn(`User-friendly error: ${errorMessage}`); │
│ } │
│ │
│ // (Removed legacy loading state helpers in favor of <LoadingState /> component) │
│ │
│ // Debouncing utility for search inputs │
│ export function debounce<T extends (...args: any[]) => void>( │
│ func: T, │
│ wait: number, │
│ ): (...args: Parameters<T>) => void { │
│ let timeout: NodeJS.Timeout; │
│ │
│ return (...args: Parameters<T>) => { │
│ clearTimeout(timeout); │
│ timeout = setTimeout(() => func(...args), wait); │
│ }; │
│ } │
│ │
│ // Local storage utilities │
│ export function saveToLocalStorage(key: string, data: any): void { │
│ try { │
│ localStorage.setItem(key, JSON.stringify(data)); │
│ } catch (error) { │
│ console.warn("Failed to save to localStorage:", error); │
│ } │
│ } │
│ │
│ export function loadFromLocalStorage<T>(key: string): T | null { │
│ try { │
│ const item = localStorage.getItem(key); │
│ return item ? JSON.parse(item) : null; │
│ } catch (error) { │
│ console.warn("Failed to load from localStorage:", error); │
│ return null; │
│ } │
│ } │
│ │
│ // Array utilities │
│ export function chunk<T>(array: T[], size: number): T[][] { │
│ const chunks: T[][] = []; │
│ for (let i = 0; i < array.length; i += size) { │
│ chunks.push(array.slice(i, i + size)); │
│ } │
│ return chunks; │
│ } │
│ │
│ export function unique<T>(array: T[]): T[] { │
│ return Array.from(new Set(array)); │
│ } │
│ │
│ // Object utilities │
│ export function isEmpty(obj: any): boolean { │
│ if (!obj) return true; │
│ if (Array.isArray(obj)) return obj.length === 0; │
│ if (typeof obj === "object") return Object.keys(obj).length === 0; │
│ return false; │
│ } │
│ │
│ // Static API utilities for file-based endpoints │
│ export function dungeonIdToSlug(dungeonId: number): string { │
│ const dungeonName = DUNGEON_MAP[dungeonId as keyof typeof DUNGEON_MAP]; │
│ if (!dungeonName) { │
│ throw new Error(`Unknown dungeon ID: ${dungeonId}`); │
│ } │
│ return dungeonNameToSlug(dungeonName); │
│ } │
│ │
│ // Build static API file paths │
│ export function buildStaticLeaderboardPath( │
│ region: string, │
│ realm: string, │
│ dungeonId: number, │
│ page: number = 1, │
│ seasonId: number = 1, │
│ ): string { │
│ const dungeonSlug = dungeonIdToSlug(dungeonId); │
│ │
│ if (region === "global") { │
│ return `/api/leaderboard/season/${seasonId}/global/${dungeonSlug}/${page}.json │
│ `; │
│ } else if (realm === "all") { │
│ return `/api/leaderboard/season/${seasonId}/${region}/all/${dungeonSlug}/${pag │
│ e}.json`; │
│ } else { │
│ return `/api/leaderboard/season/${seasonId}/${region}/${realm}/${dungeonSlug}/ │
│ ${page}.json`; │
│ } │
│ } │
│ │
│ export function buildStaticPlayerLeaderboardPath( │
│ scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ opts?: { realmSlug?: string; classKey?: string; seasonId?: number }, │
│ ): string { │
│ const cls = (opts?.classKey || "").toLowerCase(); │
│ const realm = (opts?.realmSlug || "").toLowerCase(); │
│ const seasonId = opts?.seasonId ?? 1; │
│ │
│ // Class-filtered variants │
│ if (cls) { │
│ if (scope === "global") { │
│ return `/api/leaderboard/season/${seasonId}/players/class/${cls}/global/${pa │
│ ge}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/season/${seasonId}/players/class/${cls}/regional/${ │
│ region}/${page}.json`; │
│ } │
│ if (scope === "realm" && region && realm) { │
│ return `/api/leaderboard/season/${seasonId}/players/class/${cls}/realm/${reg │
│ ion}/${realm}/${page}.json`; │
│ } │
│ } │
│ │
│ // Unfiltered │
│ if (scope === "global") { │
│ return `/api/leaderboard/season/${seasonId}/players/global/${page}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/season/${seasonId}/players/regional/${region}/${page} │
│ .json`; │
│ } │
│ if (scope === "realm" && region && realm) { │
│ return `/api/leaderboard/season/${seasonId}/players/realm/${region}/${realm}/$ │
│ {page}.json`; │
│ } │
│ // Fallback to global │
│ return `/api/leaderboard/season/${seasonId}/players/global/${page}.json`; │
│ } │
│ │
│ // Unified leaderboard URL builder for the merged Players/Characters page. │
│ // Picks the correct static JSON path for any (view, sort, season, scope) │
│ // combination. Existing trees are reused when possible; new combinations │
│ // (all-time time, per-season runs) hit the Phase 1 generators. │
│ // │
│ // Notes on URL drift between trees: │
│ // * Per-char per-season time and per-account per-season time still live at │
│ // /season/{N}/players/ and /season/{N}/accounts/ - the migration to │
│ // /season{N}/{view}/by-{sort}/ happens in Phase 3. │
│ // * Per-char and per-account all-time runs still live at │
│ // /players/total-runs/ - same reason. │
│ type LeaderboardView = "player" | "character"; │
│ type LeaderboardSort = "time" | "runs"; │
│ │
│ export function buildUnifiedLeaderboardPath( │
│ view: LeaderboardView, │
│ sort: LeaderboardSort, │
│ season: number | "all-time", │
│ scope: "global" | "regional" | "realm" | "class", │
│ opts?: { │
│ region?: string; │
│ realmSlug?: string; │
│ classKey?: string; │
│ page?: number; │
│ }, │
│ ): string { │
│ const page = opts?.page ?? 1; │
│ const region = (opts?.region || "").toLowerCase(); │
│ const realm = (opts?.realmSlug || "").toLowerCase(); │
│ const cls = (opts?.classKey || "").toLowerCase(); │
│ │
│ // Build the scope segments common to most paths. │
│ const scopeSegments = (() => { │
│ if (scope === "global") return "global"; │
│ if (scope === "regional") return `regional/${region}`; │
│ if (scope === "realm") return `realm/${region}/${realm}`; │
│ if (scope === "class") { │
│ // Class scope is followed by an inner geo (only valid for character view). │
│ // Caller signals which geo via region/realm being set: realm > region > glo │
│ bal. │
│ const inner = realm │
│ ? `realm/${region}/${realm}` │
│ : region │
│ ? `regional/${region}` │
│ : "global"; │
│ return `class/${cls}/${inner}`; │
│ } │
│ return "global"; │
│ })(); │
│ │
│ // All-Time time ? Phase 1 unified tree. │
│ if (season === "all-time" && sort === "time") { │
│ const v = view === "player" ? "players" : "characters"; │
│ return `/api/leaderboard/all-time/${v}/by-time/${scopeSegments}/${page}.json`; │
│ } │
│ // All-Time runs ? existing cross-season Total Runs trees. │
│ if (season === "all-time" && sort === "runs") { │
│ if (view === "player") { │
│ // Account total runs (no class scope - accounts span classes) │
│ if (scope === "realm") │
│ return `/api/leaderboard/players/total-runs/accounts/realm/${region}/${rea │
│ lm}/${page}.json`; │
│ if (scope === "regional") │
│ return `/api/leaderboard/players/total-runs/accounts/regional/${region}/${ │
│ page}.json`; │
│ return `/api/leaderboard/players/total-runs/accounts/global/${page}.json`; │
│ } │
│ // Per-char total runs │
│ if (scope === "class") │
│ return buildStaticTotalRunsLeaderboardPath( │
│ realm ? "realm" : region ? "regional" : "global", │
│ region, │
│ page, │
│ { realmSlug: realm || undefined, classKey: cls }, │
│ ); │
│ if (scope === "realm") │
│ return `/api/leaderboard/players/total-runs/realm/${region}/${realm}/${page} │
│ .json`; │
│ if (scope === "regional") │
│ return `/api/leaderboard/players/total-runs/regional/${region}/${page}.json` │
│ ; │
│ return `/api/leaderboard/players/total-runs/global/${page}.json`; │
│ } │
│ // Per-season time + per-season runs. │
│ // Note season=all-time doesn't reach here. │
│ const seasonNum = season as number; │
│ if (sort === "time") { │
│ // Existing trees (Phase 3 will migrate them under /season{N}/.../by-time/). │
│ if (view === "player") { │
│ if (scope === "realm") │
│ return `/api/leaderboard/season/${seasonNum}/accounts/realm/${region}/${re │
│ alm}/${page}.json`; │
│ if (scope === "regional") │
│ return `/api/leaderboard/season/${seasonNum}/accounts/regional/${region}/$ │
│ {page}.json`; │
│ return `/api/leaderboard/season/${seasonNum}/accounts/global/${page}.json`; │
│ } │
│ // character view, time, per-season ? existing per-char per-season tree │
│ return buildStaticPlayerLeaderboardPath( │
│ scope === "global" │
│ ? "global" │
│ : scope === "regional" │
│ ? "regional" │
│ : scope === "realm" │
│ ? "realm" │
│ : "global", │
│ region, │
│ page, │
│ { │
│ realmSlug: realm || undefined, │
│ classKey: cls || undefined, │
│ seasonId: seasonNum, │
│ }, │
│ ); │
│ } │
│ // sort === "runs" + per-season ? Phase 1 new tree. │
│ const v = view === "player" ? "players" : "characters"; │
│ return `/api/leaderboard/season${seasonNum}/${v}/by-runs/${scopeSegments}/${page │
│ }.json`; │
│ } │
│ │
│ // Account-grouped cross-season Total Runs (counterpart to the per-character │
│ // total runs leaderboard, used by the Player toggle on /players/total-runs/). │
│ export function buildStaticAccountTotalRunsPath( │
│ scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ opts?: { realmSlug?: string }, │
│ ): string { │
│ const realm = (opts?.realmSlug || "").toLowerCase(); │
│ if (scope === "realm" && region && realm) { │
│ return `/api/leaderboard/players/total-runs/accounts/realm/${region}/${realm}/ │
│ ${page}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/players/total-runs/accounts/regional/${region}/${page │
│ }.json`; │
│ } │
│ return `/api/leaderboard/players/total-runs/accounts/global/${page}.json`; │
│ } │
│ │
│ // account-grouped player leaderboard, per season │
│ export function buildStaticAccountLeaderboardPath( │
│ scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ opts?: { seasonId?: number; realmSlug?: string }, │
│ ): string { │
│ const seasonId = opts?.seasonId ?? 1; │
│ if (scope === "realm" && region && opts?.realmSlug) { │
│ return `/api/leaderboard/season/${seasonId}/accounts/realm/${region}/${opts.re │
│ almSlug}/${page}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/season/${seasonId}/accounts/regional/${region}/${page │
│ }.json`; │
│ } │
│ return `/api/leaderboard/season/${seasonId}/accounts/global/${page}.json`; │
│ } │
│ │
│ // cross-season tree; sits outside /season/N because the metric sums across season │
│ s │
│ export function buildStaticTotalRunsLeaderboardPath( │
│ scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ opts?: { realmSlug?: string; classKey?: string }, │
│ ): string { │
│ const cls = (opts?.classKey || "").toLowerCase(); │
│ const realm = (opts?.realmSlug || "").toLowerCase(); │
│ if (cls) { │
│ if (scope === "global") { │
│ return `/api/leaderboard/players/total-runs/class/${cls}/global/${page}.json │
│ `; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/players/total-runs/class/${cls}/regional/${region}/ │
│ ${page}.json`; │
│ } │
│ if (scope === "realm" && region && realm) { │
│ return `/api/leaderboard/players/total-runs/class/${cls}/realm/${region}/${r │
│ ealm}/${page}.json`; │
│ } │
│ } │
│ if (scope === "global") { │
│ return `/api/leaderboard/players/total-runs/global/${page}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/players/total-runs/regional/${region}/${page}.json`; │
│ } │
│ if (scope === "realm" && region && realm) { │
│ return `/api/leaderboard/players/total-runs/realm/${region}/${realm}/${page}.j │
│ son`; │
│ } │
│ return `/api/leaderboard/players/total-runs/global/${page}.json`; │
│ } │
│ │
│ export function buildStaticPlayerProfilePath( │
│ region: string, │
│ realmSlug: string, │
│ playerName: string, │
│ ): string { │
│ // Normalize to lowercase to match public/ directory structure on case-sensitive │
│ hosts │
│ const normRegion = (region || "").toLowerCase(); │
│ const normRealm = (realmSlug || "").toLowerCase(); │
│ const normPlayer = (playerName || "").toLowerCase(); │
│ return `/api/player/${normRegion}/${normRealm}/${normPlayer}.json`; │
│ } │
│ │
│ export function buildStaticSearchIndexPath(shardNumber: number): string { │
│ const paddedNumber = shardNumber.toString().padStart(3, "0"); │
│ return `/api/search/players-${paddedNumber}.json`; │
│ } │
│ │
│ // Status page utilities │
│ export function filterByRegion<T extends { region?: string }>( │
│ items: T[], │
│ region: string, │
│ ): T[] { │
│ if (!region) return items; │
│ const normalized = region.toLowerCase(); │
│ return items.filter( │
│ (item) => (item.region || "").toLowerCase() === normalized, │
│ ); │
│ } │
│ │
│ export function safeArray<T>(value: any): T[] { │
│ return Array.isArray(value) ? value : []; │
│ } │
│ │
│ export const STATUS_CONSTANTS = { │
│ MODE: "status" as const, │
│ DELIMITER: "|" as const, │
│ REGION_REALM_SEPARATOR: "|" as const, │
│ } as const; │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ TS ─────────────────────────────────┐
│ // Common utility functions │
│ import { DUNGEON_MAP, dungeonNameToSlug } fr │
│ om "./wow-constants.js"; │
│ │
│ // Time formatting utilities │
│ export function formatDuration(seconds: numb │
│ er): string { │
│ const minutes = Math.floor(seconds / 60); │
│ const remainingSeconds = seconds % 60; │
│ return minutes > 0 │
│ ? `${minutes}m ${remainingSeconds}s` │
│ : `${remainingSeconds}s`; │
│ } │
│ │
│ export function formatDurationMMSS(milliseco │
│ nds: number): string { │
│ return `${Math.floor(milliseconds / 60000) │
│ }:${String(Math.floor((milliseconds % 60000) │
│ / 1000)).padStart(2, "0")}`; │
│ } │
│ │
│ // Alias for clarity when working with milli │
│ seconds │
│ export const formatDurationFromMs = formatDu │
│ rationMMSS; │
│ │
│ export function formatTimestamp(timestamp: n │
│ umber): string { │
│ return new Date(timestamp).toLocaleDateStr │
│ ing("en-US", { │
│ month: "short", │
│ day: "numeric", │
│ hour: "2-digit", │
│ minute: "2-digit", │
│ }); │
│ } │
│ │
│ // compact "Mar 27"; formatTimestamp's time- │
│ of-day eats space in dense layouts │
│ export function formatShortDate(timestamp: n │
│ umber): string { │
│ return new Date(timestamp).toLocaleDateStr │
│ ing("en-US", { │
│ month: "short", │
│ day: "numeric", │
│ }); │
│ } │
│ │
│ export function formatSimulationDate(timesta │
│ mp: string | number): string { │
│ return new Date(timestamp).toLocaleString( │
│ "en-US", { │
│ year: "numeric", │
│ month: "long", │
│ day: "numeric", │
│ hour: "2-digit", │
│ minute: "2-digit", │
│ timeZoneName: "short", │
│ }); │
│ } │
│ │
│ export function formatRaidBuffs( │
│ buffs: Record<string, boolean | number>, │
│ ): string { │
│ const MINOR_WORDS = new Set([ │
│ "of", │
│ "the", │
│ "and", │
│ "to", │
│ "for", │
│ "a", │
│ "an", │
│ "in", │
│ "on", │
│ "with", │
│ "vs", │
│ ]); │
│ │
│ const normalize = (key: string): string => │
│ { │
│ if (!key) return ""; │
│ let s = String(key); │
│ // Drop trailing Count (e.g., skullBanne │
│ rCount -> skullBanner) │
│ s = s.replace(/Count$/i, ""); │
│ // Replace underscores with spaces and s │
│ plit camelCase │
│ s = s.replace(/_/g, " "); │
│ s = s.replace(/([a-z])([A-Z])/g, "$1 $2" │
│ ); │
│ // Collapse whitespace │
│ s = s.replace(/\s+/g, " ").trim().toLowe │
│ rCase(); │
│ // Title-case while keeping minor words │
│ lowercase │
│ const words = s.split(" "); │
│ const out = words.map((w, i) => { │
│ if (i > 0 && MINOR_WORDS.has(w)) retur │
│ n w; │
│ return w.charAt(0).toUpperCase() + w.s │
│ lice(1); │
│ }); │
│ return out.join(" "); │
│ }; │
│ │
│ const activeBuffs = Object.entries(buffs) │
│ .filter(([_, value]) => value !== false │
│ && value !== 0) │
│ .map(([key, value]) => { │
│ const name = normalize(key); │
│ return typeof value === "number" && va │
│ lue > 1 │
│ ? `${name} (${value})` │
│ : name; │
│ }); │
│ return activeBuffs.join(", "); │
│ } │
│ │
│ export function formatRace(race: string): st │
│ ring { │
│ if (!race) return "Unknown"; │
│ let s = String(race).trim(); │
│ // Strip common enum prefixes like "RaceOr │
│ c" -> "Orc" │
│ if (/^Race[A-Z]/.test(s)) s = s.replace(/^ │
│ Race/, ""); │
│ // Replace underscores with spaces and spl │
│ it CamelCase into words │
│ s = s.replace(/_/g, " "); │
│ s = s.replace(/([a-z])([A-Z])/g, "$1 $2"); │
│ // Normalize extra whitespace │
│ s = s.replace(/\s+/g, " ").trim(); │
│ // Title case each word │
│ s = s.toLowerCase().replace(/\b\w/g, (l) = │
│ > l.toUpperCase()); │
│ return s; │
│ } │
│ │
│ // General string helpers │
│ export function toTitleCase(input: string): │
│ string { │
│ if (!input) return ""; │
│ const s = String(input).replace(/[_-]+/g, │
│ " ").trim().toLowerCase(); │
│ return s.replace(/\b\w/g, (l) => l.toUpper │
│ Case()); │
│ } │
│ │
│ export function slugifyLabel(input: string): │
│ string { │
│ return String(input || "") │
│ .trim() │
│ .toLowerCase() │
│ .replace(/\s+/g, "_"); │
│ } │
│ │
│ // DOM utilities │
│ export function createElement( │
│ tag: string, │
│ className?: string, │
│ textContent?: string, │
│ ): HTMLElement { │
│ const element = document.createElement(tag │
│ ); │
│ if (className) element.className = classNa │
│ me; │
│ if (textContent) element.textContent = tex │
│ tContent; │
│ return element; │
│ } │
│ │
│ export function toggleElementVisibility( │
│ element: HTMLElement, │
│ show: boolean, │
│ ): void { │
│ element.style.display = show ? "block" : " │
│ none"; │
│ } │
│ │
│ // URL utilities │
│ export function updateBrowserURL(url: string │
│ ): void { │
│ window.history.pushState({}, "", url); │
│ } │
│ │
│ export function getURLSearchParams(): URLSea │
│ rchParams { │
│ return new URLSearchParams(window.location │
│ .search); │
│ } │
│ │
│ export function buildLeaderboardURL( │
│ region: string, │
│ realm: string, │
│ dungeonSlug: string, │
│ page?: number, │
│ opts?: { season?: number; basePath?: strin │
│ g }, │
│ ): string { │
│ const season = Number(opts?.season ?? 1); │
│ const basePath = opts?.basePath ?? "challe │
│ nge-mode"; │
│ const seasonSegment = season > 0 ? `season │
│ ${season}` : "season1"; │
│ │
│ const normalizedRegion = region || "global │
│ "; │
│ const normalizedRealm = realm && realm !== │
│ "" ? realm : "all"; │
│ │
│ let baseURL: string; │
│ │
│ if (normalizedRegion === "global") { │
│ baseURL = `/${basePath}/${seasonSegment} │
│ /global/${dungeonSlug}`; │
│ } else { │
│ const realmSlug = normalizedRealm || "al │
│ l"; │
│ baseURL = `/${basePath}/${seasonSegment} │
│ /${normalizedRegion}/${realmSlug}/${dungeonS │
│ lug}`; │
│ } │
│ │
│ if (page && page > 1) { │
│ baseURL += `?page=${page}`; │
│ } │
│ │
│ return baseURL; │
│ } │
│ │
│ export function buildPlayerProfileURL( │
│ region: string, │
│ realmSlug: string, │
│ playerName: string, │
│ ): string { │
│ return `/player/${region}/${realmSlug}/${p │
│ layerName.toLowerCase()}`; │
│ } │
│ │
│ // Build Players tab URL path from filters │
│ export function buildPlayersLeaderboardURL( │
│ scope: "global" | "regional" | "realm", │
│ opts?: { │
│ region?: string; │
│ realmSlug?: string; │
│ classKey?: string; │
│ page?: number; │
│ }, │
│ ): string { │
│ const cls = (opts?.classKey || "").toLower │
│ Case(); │
│ const reg = (opts?.region || "").toLowerCa │
│ se(); │
│ const realm = (opts?.realmSlug || "").toLo │
│ werCase(); │
│ let path = "/challenge-mode/players"; │
│ if (scope === "global") { │
│ if (cls) path += `/global/${cls}`; │
│ else path += `/global`; │
│ } else if (scope === "regional") { │
│ if (reg) path += `/${reg}`; │
│ else path += `/us`; │
│ if (cls) path += `/${cls}`; │
│ } else if (scope === "realm") { │
│ if (reg) path += `/${reg}`; │
│ if (realm) path += `/${realm}`; │
│ if (cls) path += `/${cls}`; │
│ } │
│ const page = opts?.page || 1; │
│ if (page > 1) path += `?page=${page}`; │
│ return path; │
│ } │
│ │
│ // Form utilities │
│ export function getFormValue(elementId: stri │
│ ng): string { │
│ const element = document.getElementById(el │
│ ementId) as │
│ | HTMLInputElement │
│ | HTMLSelectElement; │
│ return element?.value || ""; │
│ } │
│ │
│ export function setFormValue(elementId: stri │
│ ng, value: string): void { │
│ const element = document.getElementById(el │
│ ementId) as │
│ | HTMLInputElement │
│ | HTMLSelectElement; │
│ if (element) element.value = value; │
│ } │
│ │
│ // Validation utilities │
│ export function validateRequiredFields( │
│ ...values: (string | undefined)[] │
│ ): boolean { │
│ return values.every((value) => value && va │
│ lue.trim().length > 0); │
│ } │
│ │
│ // Error handling utilities │
│ export function handleAPIError(error: Error, │
│ context: string): void { │
│ console.error(`${context} error:`, error); │
│ │
│ // Could extend this to show user-friendly │
│ error messages │
│ const errorMessage = error.message.include │
│ s("404") │
│ ? "Data not found" │
│ : error.message.includes("500") │
│ ? "Server error - please try again lat │
│ er" │
│ : "An error occurred loading data"; │
│ │
│ console.warn(`User-friendly error: ${error │
│ Message}`); │
│ } │
│ │
│ // (Removed legacy loading state helpers in │
│ favor of <LoadingState /> component) │
│ │
│ // Debouncing utility for search inputs │
│ export function debounce<T extends (...args: │
│ any[]) => void>( │
│ func: T, │
│ wait: number, │
│ ): (...args: Parameters<T>) => void { │
│ let timeout: NodeJS.Timeout; │
│ │
│ return (...args: Parameters<T>) => { │
│ clearTimeout(timeout); │
│ timeout = setTimeout(() => func(...args) │
│ , wait); │
│ }; │
│ } │
│ │
│ // Local storage utilities │
│ export function saveToLocalStorage(key: stri │
│ ng, data: any): void { │
│ try { │
│ localStorage.setItem(key, JSON.stringify │
│ (data)); │
│ } catch (error) { │
│ console.warn("Failed to save to localSto │
│ rage:", error); │
│ } │
│ } │
│ │
│ export function loadFromLocalStorage<T>(key: │
│ string): T | null { │
│ try { │
│ const item = localStorage.getItem(key); │
│ return item ? JSON.parse(item) : null; │
│ } catch (error) { │
│ console.warn("Failed to load from localS │
│ torage:", error); │
│ return null; │
│ } │
│ } │
│ │
│ // Array utilities │
│ export function chunk<T>(array: T[], size: n │
│ umber): T[][] { │
│ const chunks: T[][] = []; │
│ for (let i = 0; i < array.length; i += siz │
│ e) { │
│ chunks.push(array.slice(i, i + size)); │
│ } │
│ return chunks; │
│ } │
│ │
│ export function unique<T>(array: T[]): T[] { │
│ return Array.from(new Set(array)); │
│ } │
│ │
│ // Object utilities │
│ export function isEmpty(obj: any): boolean { │
│ if (!obj) return true; │
│ if (Array.isArray(obj)) return obj.length │
│ === 0; │
│ if (typeof obj === "object") return Object │
│ .keys(obj).length === 0; │
│ return false; │
│ } │
│ │
│ // Static API utilities for file-based endpo │
│ ints │
│ export function dungeonIdToSlug(dungeonId: n │
│ umber): string { │
│ const dungeonName = DUNGEON_MAP[dungeonId │
│ as keyof typeof DUNGEON_MAP]; │
│ if (!dungeonName) { │
│ throw new Error(`Unknown dungeon ID: ${d │
│ ungeonId}`); │
│ } │
│ return dungeonNameToSlug(dungeonName); │
│ } │
│ │
│ // Build static API file paths │
│ export function buildStaticLeaderboardPath( │
│ region: string, │
│ realm: string, │
│ dungeonId: number, │
│ page: number = 1, │
│ seasonId: number = 1, │
│ ): string { │
│ const dungeonSlug = dungeonIdToSlug(dungeo │
│ nId); │
│ │
│ if (region === "global") { │
│ return `/api/leaderboard/season/${season │
│ Id}/global/${dungeonSlug}/${page}.json`; │
│ } else if (realm === "all") { │
│ return `/api/leaderboard/season/${season │
│ Id}/${region}/all/${dungeonSlug}/${page}.jso │
│ n`; │
│ } else { │
│ return `/api/leaderboard/season/${season │
│ Id}/${region}/${realm}/${dungeonSlug}/${page │
│ }.json`; │
│ } │
│ } │
│ │
│ export function buildStaticPlayerLeaderboard │
│ Path( │
│ scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ opts?: { realmSlug?: string; classKey?: st │
│ ring; seasonId?: number }, │
│ ): string { │
│ const cls = (opts?.classKey || "").toLower │
│ Case(); │
│ const realm = (opts?.realmSlug || "").toLo │
│ werCase(); │
│ const seasonId = opts?.seasonId ?? 1; │
│ │
│ // Class-filtered variants │
│ if (cls) { │
│ if (scope === "global") { │
│ return `/api/leaderboard/season/${seas │
│ onId}/players/class/${cls}/global/${page}.js │
│ on`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/season/${seas │
│ onId}/players/class/${cls}/regional/${region │
│ }/${page}.json`; │
│ } │
│ if (scope === "realm" && region && realm │
│ ) { │
│ return `/api/leaderboard/season/${seas │
│ onId}/players/class/${cls}/realm/${region}/$ │
│ {realm}/${page}.json`; │
│ } │
│ } │
│ │
│ // Unfiltered │
│ if (scope === "global") { │
│ return `/api/leaderboard/season/${season │
│ Id}/players/global/${page}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/season/${season │
│ Id}/players/regional/${region}/${page}.json` │
│ ; │
│ } │
│ if (scope === "realm" && region && realm) │
│ { │
│ return `/api/leaderboard/season/${season │
│ Id}/players/realm/${region}/${realm}/${page} │
│ .json`; │
│ } │
│ // Fallback to global │
│ return `/api/leaderboard/season/${seasonId │
│ }/players/global/${page}.json`; │
│ } │
│ │
│ // Unified leaderboard URL builder for the m │
│ erged Players/Characters page. │
│ // Picks the correct static JSON path for an │
│ y (view, sort, season, scope) │
│ // combination. Existing trees are reused wh │
│ en possible; new combinations │
│ // (all-time time, per-season runs) hit the │
│ Phase 1 generators. │
│ // │
│ // Notes on URL drift between trees: │
│ // * Per-char per-season time and per-acco │
│ unt per-season time still live at │
│ // /season/{N}/players/ and /season/{N}/ │
│ accounts/ - the migration to │
│ // /season{N}/{view}/by-{sort}/ happens │
│ in Phase 3. │
│ // * Per-char and per-account all-time run │
│ s still live at │
│ // /players/total-runs/ - same reason. │
│ type LeaderboardView = "player" | "character │
│ "; │
│ type LeaderboardSort = "time" | "runs"; │
│ │
│ export function buildUnifiedLeaderboardPath( │
│ view: LeaderboardView, │
│ sort: LeaderboardSort, │
│ season: number | "all-time", │
│ scope: "global" | "regional" | "realm" | " │
│ class", │
│ opts?: { │
│ region?: string; │
│ realmSlug?: string; │
│ classKey?: string; │
│ page?: number; │
│ }, │
│ ): string { │
│ const page = opts?.page ?? 1; │
│ const region = (opts?.region || "").toLowe │
│ rCase(); │
│ const realm = (opts?.realmSlug || "").toLo │
│ werCase(); │
│ const cls = (opts?.classKey || "").toLower │
│ Case(); │
│ │
│ // Build the scope segments common to most │
│ paths. │
│ const scopeSegments = (() => { │
│ if (scope === "global") return "global"; │
│ if (scope === "regional") return `region │
│ al/${region}`; │
│ if (scope === "realm") return `realm/${r │
│ egion}/${realm}`; │
│ if (scope === "class") { │
│ // Class scope is followed by an inner │
│ geo (only valid for character view). │
│ // Caller signals which geo via region │
│ /realm being set: realm > region > global. │
│ const inner = realm │
│ ? `realm/${region}/${realm}` │
│ : region │
│ ? `regional/${region}` │
│ : "global"; │
│ return `class/${cls}/${inner}`; │
│ } │
│ return "global"; │
│ })(); │
│ │
│ // All-Time time ? Phase 1 unified tree. │
│ if (season === "all-time" && sort === "tim │
│ e") { │
│ const v = view === "player" ? "players" │
│ : "characters"; │
│ return `/api/leaderboard/all-time/${v}/b │
│ y-time/${scopeSegments}/${page}.json`; │
│ } │
│ // All-Time runs ? existing cross-season T │
│ otal Runs trees. │
│ if (season === "all-time" && sort === "run │
│ s") { │
│ if (view === "player") { │
│ // Account total runs (no class scope │
│ - accounts span classes) │
│ if (scope === "realm") │
│ return `/api/leaderboard/players/tot │
│ al-runs/accounts/realm/${region}/${realm}/${ │
│ page}.json`; │
│ if (scope === "regional") │
│ return `/api/leaderboard/players/tot │
│ al-runs/accounts/regional/${region}/${page}. │
│ json`; │
│ return `/api/leaderboard/players/total │
│ -runs/accounts/global/${page}.json`; │
│ } │
│ // Per-char total runs │
│ if (scope === "class") │
│ return buildStaticTotalRunsLeaderboard │
│ Path( │
│ realm ? "realm" : region ? "regional │
│ " : "global", │
│ region, │
│ page, │
│ { realmSlug: realm || undefined, cla │
│ ssKey: cls }, │
│ ); │
│ if (scope === "realm") │
│ return `/api/leaderboard/players/total │
│ -runs/realm/${region}/${realm}/${page}.json` │
│ ; │
│ if (scope === "regional") │
│ return `/api/leaderboard/players/total │
│ -runs/regional/${region}/${page}.json`; │
│ return `/api/leaderboard/players/total-r │
│ uns/global/${page}.json`; │
│ } │
│ // Per-season time + per-season runs. │
│ // Note season=all-time doesn't reach here │
│ . │
│ const seasonNum = season as number; │
│ if (sort === "time") { │
│ // Existing trees (Phase 3 will migrate │
│ them under /season{N}/.../by-time/). │
│ if (view === "player") { │
│ if (scope === "realm") │
│ return `/api/leaderboard/season/${se │
│ asonNum}/accounts/realm/${region}/${realm}/$ │
│ {page}.json`; │
│ if (scope === "regional") │
│ return `/api/leaderboard/season/${se │
│ asonNum}/accounts/regional/${region}/${page} │
│ .json`; │
│ return `/api/leaderboard/season/${seas │
│ onNum}/accounts/global/${page}.json`; │
│ } │
│ // character view, time, per-season ? ex │
│ isting per-char per-season tree │
│ return buildStaticPlayerLeaderboardPath( │
│ scope === "global" │
│ ? "global" │
│ : scope === "regional" │
│ ? "regional" │
│ : scope === "realm" │
│ ? "realm" │
│ : "global", │
│ region, │
│ page, │
│ { │
│ realmSlug: realm || undefined, │
│ classKey: cls || undefined, │
│ seasonId: seasonNum, │
│ }, │
│ ); │
│ } │
│ // sort === "runs" + per-season ? Phase 1 │
│ new tree. │
│ const v = view === "player" ? "players" : │
│ "characters"; │
│ return `/api/leaderboard/season${seasonNum │
│ }/${v}/by-runs/${scopeSegments}/${page}.json │
│ `; │
│ } │
│ │
│ // Account-grouped cross-season Total Runs ( │
│ counterpart to the per-character │
│ // total runs leaderboard, used by the Playe │
│ r toggle on /players/total-runs/). │
│ export function buildStaticAccountTotalRunsP │
│ ath( │
│ scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ opts?: { realmSlug?: string }, │
│ ): string { │
│ const realm = (opts?.realmSlug || "").toLo │
│ werCase(); │
│ if (scope === "realm" && region && realm) │
│ { │
│ return `/api/leaderboard/players/total-r │
│ uns/accounts/realm/${region}/${realm}/${page │
│ }.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/players/total-r │
│ uns/accounts/regional/${region}/${page}.json │
│ `; │
│ } │
│ return `/api/leaderboard/players/total-run │
│ s/accounts/global/${page}.json`; │
│ } │
│ │
│ // account-grouped player leaderboard, per s │
│ eason │
│ export function buildStaticAccountLeaderboar │
│ dPath( │
│ scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ opts?: { seasonId?: number; realmSlug?: st │
│ ring }, │
│ ): string { │
│ const seasonId = opts?.seasonId ?? 1; │
│ if (scope === "realm" && region && opts?.r │
│ ealmSlug) { │
│ return `/api/leaderboard/season/${season │
│ Id}/accounts/realm/${region}/${opts.realmSlu │
│ g}/${page}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/season/${season │
│ Id}/accounts/regional/${region}/${page}.json │
│ `; │
│ } │
│ return `/api/leaderboard/season/${seasonId │
│ }/accounts/global/${page}.json`; │
│ } │
│ │
│ // cross-season tree; sits outside /season/N │
│ because the metric sums across seasons │
│ export function buildStaticTotalRunsLeaderbo │
│ ardPath( │
│ scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ opts?: { realmSlug?: string; classKey?: st │
│ ring }, │
│ ): string { │
│ const cls = (opts?.classKey || "").toLower │
│ Case(); │
│ const realm = (opts?.realmSlug || "").toLo │
│ werCase(); │
│ if (cls) { │
│ if (scope === "global") { │
│ return `/api/leaderboard/players/total │
│ -runs/class/${cls}/global/${page}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/players/total │
│ -runs/class/${cls}/regional/${region}/${page │
│ }.json`; │
│ } │
│ if (scope === "realm" && region && realm │
│ ) { │
│ return `/api/leaderboard/players/total │
│ -runs/class/${cls}/realm/${region}/${realm}/ │
│ ${page}.json`; │
│ } │
│ } │
│ if (scope === "global") { │
│ return `/api/leaderboard/players/total-r │
│ uns/global/${page}.json`; │
│ } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/players/total-r │
│ uns/regional/${region}/${page}.json`; │
│ } │
│ if (scope === "realm" && region && realm) │
│ { │
│ return `/api/leaderboard/players/total-r │
│ uns/realm/${region}/${realm}/${page}.json`; │
│ } │
│ return `/api/leaderboard/players/total-run │
│ s/global/${page}.json`; │
│ } │
│ │
│ export function buildStaticPlayerProfilePath │
│ ( │
│ region: string, │
│ realmSlug: string, │
│ playerName: string, │
│ ): string { │
│ // Normalize to lowercase to match public/ │
│ directory structure on case-sensitive hosts │
│ const normRegion = (region || "").toLowerC │
│ ase(); │
│ const normRealm = (realmSlug || "").toLowe │
│ rCase(); │
│ const normPlayer = (playerName || "").toLo │
│ werCase(); │
│ return `/api/player/${normRegion}/${normRe │
│ alm}/${normPlayer}.json`; │
│ } │
│ │
│ export function buildStaticSearchIndexPath(s │
│ hardNumber: number): string { │
│ const paddedNumber = shardNumber.toString( │
│ ).padStart(3, "0"); │
│ return `/api/search/players-${paddedNumber │
│ }.json`; │
│ } │
│ │
│ // Status page utilities │
│ export function filterByRegion<T extends { r │
│ egion?: string }>( │
│ items: T[], │
│ region: string, │
│ ): T[] { │
│ if (!region) return items; │
│ const normalized = region.toLowerCase(); │
│ return items.filter( │
│ (item) => (item.region || "").toLowerCas │
│ e() === normalized, │
│ ); │
│ } │
│ │
│ export function safeArray<T>(value: any): T[ │
│ ] { │
│ return Array.isArray(value) ? value : []; │
│ } │
│ │
│ export const STATUS_CONSTANTS = { │
│ MODE: "status" as const, │
│ DELIMITER: "|" as const, │
│ REGION_REALM_SEPARATOR: "|" as const, │
│ } as const; │
└──────────────────────────────────────────────┘