1/* /app leaderboard. All data goes through window.TDTT.lb â this file does no 2 * fetching of its own and contains no pure logic worth testing; anything that 3 * qualifies belongs in packages/shared/src/leaderboard/normalize.ts instead. 4 * 5 * Markup shapes are copied from apps/server/public/shared/_preview.html (the 6 * committed design reference for .tdtt-banner / .tdtt-statrow / .tdtt-stat / 7 * .tdtt-table / .tdtt-table__name / .tdtt-table__num / .tdtt-table__medal / 8 * .tdtt-pager / .tdtt-avatar), not invented here. leaderboard.css holds only 9 * the layout this view needs that those shared primitives do not already 10 * provide. 11 * 12 * The three-column podium stays retired (2026-09-10). The player list's Top 5 13 * Heroes column was retired with it and RESTORED on 2026-09-22 by owner 14 * ruling, scoped to Dice Throne only â see showsTopCharacters(). It renders 15 * through window.TDTT.profileRender.heroStripHtml, the same primitive the 16 * drill-down panel uses; do not transliterate a second copy here. The public 17 * landing page and /m keep the column retired. 18 * 19 * The banner's scope filter (Ranked · Online / Online Tournaments / 20 * In-Person) is .chip, and the mode tabs (Players/Heroes/Drafts/Bans) are 21 * .seg-slab â a deliberate, non-unifiable distinction: a chip is a filter 22 * (which list you're looking at), a slab segment is a mode switch (within 23 * that list). 24 * 25 * Task 9 (2026-09-22) adds `?view=`/`?hero=` deep-link routing and the Heroes 26 * landing card grid. The hero DETAIL page and the Drafts/Bans views are 27 * separate modules this file only DELEGATES to â 28 * window.TDTT.lbHeroPage (Task 10, lb-hero-page.js) and 29 * window.TDTT.lbDraftsBans (Task 11, lb-drafts-bans.js). This file owns every 30 * fetch (heroes/matchups/heroGames/roster/draftStats/bans) and builds the 31 * `ctx` object each module renders from â see buildHeroPageCtx() and 32 * buildDraftsBansCtx() for the exact contracts. Neither module exists until 33 * its own task ships, so every delegation point guards for a missing module 34 * and falls back to loadingHtml() rather than throwing. */ 35(function () { 36 'use strict'; 37 38 var S = { 39 view: 'players', // 'players' | 'heroes' | 'drafts' | 'bans' 40 game: (function () { 41 try { 42 return localStorage.getItem('tdtt_lb_game') || 'DICE_THRONE'; 43 } catch (e) { 44 return 'DICE_THRONE'; 45 } 46 })(), 47 scope: 'ranked', // 'ranked' | 'tournament' | 'throne_room' 48 region: null, 49 sort: 'rating', // 'rating' | 'wins' | 'games' | 'winrate' 50 // Shared with desktop via lbCore so the two surfaces cannot report 51 // different page counts or a differently-scoped win-rate tile. 52 // leaderboard-core.js is loaded before this file in index.html. 53 limit: window.TDTT.lbCore.LEADERBOARD_PAGE_SIZE, 54 offset: 0, 55 rows: [], 56 total: null, // null = the endpoint cannot report one 57 totals: null, // { rankedGames } | null â the population aggregate for the stat tiles 58 loading: false, 59 error: null, 60 openPlayerId: null, // Task 9 61 profile: null, // the loaded /players/:id/profile payload for openPlayerId, or null 62 profileLoading: false, 63 profileErrorKind: null, // null | 'signin' (401) | 'notfound' (404) | 'generic' 64 profileErrorMessage: null, // detail text for the 'generic' kind 65 profileOpenGame: null, // which By-Game accordion row is expanded 66 myPlaceStatus: null, // null | 'loading' | a message to show (e.g. unranked/error) 67 68 // "Your Rank" card (main column, per E4.1 â see rankInfoRowHtml()). 69 // Independent of S.sort/S.scope â see loadMyRank()'s comment for why. 70 // Shape: null (not asked yet) or 71 // { status: 'signin' | 'loading' | 'unranked' | 'error' | 'ready', rank, total }. 72 myRank: null, 73 74 // ââ Heroes landing (Task 9) âââââââââââââââââââââââââââââââââââââââââââââ 75 heroSort: 'winRate', // one of the seven HeroSortKey values â see HERO_SORTS 76 heroQuery: '', // live text filter, matched via lb.matchesHeroQuery 77 heroes: [], // the all-time (unwindowed) HeroStatRow[] for the CURRENT S.game 78 heroTotalGames: 0, 79 // The game S.heroes was fetched for, or null when nothing is loaded. Doubles 80 // as the staleness check on a game change â a boolean `heroesLoaded` could 81 // not tell DICE_THRONE's heroes from another game's. 82 heroesGame: null, 83 heroesLoading: false, 84 heroesError: null, 85 86 // Top Hero Trends aside â an INDEPENDENT fetch from S.heroes above, its own
87 // .seg-slab window (7D/30D/90D/ALL, default 30D), matching desktop's 88 // HeroTrendsPanel.tsx. 'all' shares leaderboard-data.js's cache key with 89 // the bare heroes(game) call above, so switching to ALL never doubles the 90 // fetch. `data` is the raw { totalGames, characters } payload or null. 91 heroTrend: { window: '30d', loading: true, data: null }, 92 // The game S.heroTrend was last fetched for â same staleness-check role 93 // as heroesGame above, so re-entering the Heroes tab for the SAME game 94 // doesn't spend a request the 60s cache would answer for free anyway, but 95 // switching games always refetches. 96 heroTrendGame: null, 97 98 // ââ Hero detail page (Task 10, window.TDTT.lbHeroPage) ââââââââââââââââââ 99 // Non-null only while a `?hero=<slug>` deep link is open â see 100 // buildHeroPageCtx()'s own comment for the exact contract this feeds 101 // Task 10. Opening/closing it never changes S.view away from 'heroes'. 102 heroSlug: null, 103 heroPage: null, 104 105 // ââ Drafts / Bans (Task 11, window.TDTT.lbDraftsBans) âââââââââââââââââââ 106 // See buildDraftsBansCtx()'s own comment for the exact contract. ONE 107 // shape serves both draftsHtml(ctx) and bansHtml(ctx) since only one is 108 // ever on screen at a time. 109 draftsBans: { 110 draft: null, // draftStats() payload { totalDrafts, characters } | null 111 draftState: 'loading', // 'loading' | 'ready' | 'error' 112 heroes: null, // HeroStatRow[] | null â fetched for Drafts only 113 // 'loading' | 'ready' | 'error' â lets Task 11 tell "still fetching the 114 // heroes list to join against draftRows()" apart from "fetched, and 115 // this hero genuinely has no games" (draftRows() already renders the 116 // latter as winRate:null, i.e. the SAME dash â this just lets the 117 // still-loading case reach that dash honestly instead of by accident). 118 heroesState: 'loading', 119 bans: null, // bans(game) payload { heroes, cards } | null â Bans only 120 bansState: 'loading', 121 rosterSize: null, // number | null â null until roster() resolves, renders 'â' 122 sort: 'presence', // active DraftSortKey (Drafts only) 123 query: '', // active search string (Drafts only) 124 }, 125 126 // The loaded player rows belong to a game/view that is no longer on 127 // screen â set whenever a non-Players view is active, since the player 128 // list is not on screen then and must not be refetched until it is. 129 playersDirty: false, 130 131 // ââ TCG Players tab (Task 8, non-Dice-Throne games only â see 132 // showsTcgFeatures()) â format select, Favorite Deck column, Active Deck 133 // Archetypes tile, insights rail. Mirrors desktop's Leaderboard.tsx 134 // `format` state exactly: a format id (never null), '' meaning "All 135 // formats", so it goes straight onto players()'s params. 136 format: '', 137 138 // The rail (Insights / Top Archetypes / Recent Tournaments / Meta Notes) 139 // and the Active Deck Archetypes stat tile â ALWAYS the all-time, 140 // all-format picture (window 'all'), independent of S.format/S.offset, 141 // per the brief. `game` records which game this was last fetched for so 142 // a return to Players for the SAME game doesn't spend three requests the 143 // 60s response cache would answer for free anyway â see 144 // loadTcgRailIfStale(). 145 tcgRail: { 146 game: null, 147 decksState: 'loading', // 'loading' | 'ready' | 'error' 148 decks: [], // DeckArchetypeRow[] 149 tournamentsState: 'loading', 150 tournaments: [], // RecentTournament[] 151 formats: undefined, // FormatStatRow[] | undefined â undefined on failure (optional rail prop) 152 }, 153 154 // ââ Formats tab (Task 8, non-Dice-Throne games only) ââââââââââââââââââââ 155 // Owns ONE window-scoped fetch (default 'all') that feeds both the card 156 // grid and the Formats rail â batch ruling, mirrors desktop's 157 // FormatsView owning its own window state above both the grid and the 158 // rail's Format Trends chips. 159 formatsTab: { 160 game: null, 161 window: 'all', 162 state: 'loading', // 'loading' | 'ready' | 'error' 163 data: [], // FormatStatsRow[] 164 }, 165 166 // ââ Decks tab (Task 9, non-Dice-Throne games only) ââââââââââââââââââââââ 167 // The landing grid and the archetype detail page share ONE window state 168 // (batch ruling, mirrors desktop's DecksView: "the archetype page's 169 // window follows the Decks view's window chips" â not an independent 170 // copy that merely starts in sync). `format`/`sort`/`query` are grid-only 171 // and purely client-side (window.TDTT.lbDecks.sortDecks/matchesDeckQuery 172 // over the already-fetched list) â no refetch on a sort/search change. 173 // `token` is the request-token guard acceptResponse() reads: bumped by 174 // every loadDecksGrid() call, so a late response for a game/window/format 175 // that's since changed is ignored (Review Focus 5). 176 decksTab: { 177 window: '30d', 178 format: '', 179 sort: 'share', 180 query: '', 181 game: null, // the (game,window,format) the grid was last fetched for â 182 fetchedWindow: null, // compared against S.game/window/format by 183 fetchedFormat: null, // loadDecksGridIfStale() to decide whether a
184 // return to the Decks tab (or closing the archetype page) needs a 185 // fresh fetch or can reuse what's already loaded. 186 gridState: 'loading', // 'loading' | 'ready' | 'error' 187 decks: [], // DeckArchetypeRow[] 188 totals: null, // { trackedGames, decklessGames } | null 189 token: 0, 190 }, 191 192 // Non-null only while a `?deck=<key>` deep link is open â deep-linked the 193 // same way S.heroSlug is. Opening/closing it never changes S.view away 194 // from 'decks'. 195 deckKey: null, 196 deckPage: { 197 detailState: 'loading', // 'loading' | 'ready' | 'error' | 'notFound' 198 detail: null, // { archetype, pilots, formats, trend } | null 199 matchupsState: 'loading', 200 matchups: null, 201 gamesState: 'loading', 202 games: null, 203 token: 0, // shared by all three fetches â they all key off (game,key,window) 204 }, 205 }; 206 207 var root; 208 var regionGroups = null; // filled lazily from lb.regions() 209 var searchTimer = null; 210 var searchSeq = 0; 211 var escWired = false; // guards the document-level Escape listener below 212 // Bumped on every openPlayerProfile()/closePlayerProfile() call so a slow 213 // profile() response for a player the user has since navigated away from 214 // (opened someone else, or closed the panel) is dropped instead of painting 215 // over whatever is now on screen. 216 var profileSeq = 0; 217 // The same stale-response guard for the hero detail page (Task 10): 218 // switching hero A -> hero B, or leaving the page entirely, must not let a 219 // slower response for A paint over B (or over the grid). Bumped on every 220 // openHeroPage()/closeHeroPage() and on unmount. 221 var heroPageSeq = 0; 222 // A SECOND, narrower guard for just the window-scoped fetches inside the 223 // hero page (the windowed heroes-list lookup + matchups) â bumped on every 224 // onHeroWindow() call too, so switching windows without switching heroes 225 // doesn't need to invalidate the (window-independent) recent-games fetch. 226 var heroPageWindowSeq = 0; 227 // Stale-response guard for the Top Hero Trends aside's own window-scoped 228 // fetch â independent of heroPageWindowSeq, since the trends aside and an 229 // open hero page are unrelated fetches that can be in flight together. 230 var heroTrendSeq = 0; 231 // Stale-response guard for the Drafts/Bans fetches â bumped on every 232 // loadDraftsData()/loadBansData() call (including a game switch) so an 233 // older in-flight request can't paint over a newer one. 234 var draftsBansSeq = 0; 235 // The same stale-response guard for the primary list fetch (load()) and 236 // showMyPlace()'s own fetch chain â both mutate S.offset/S.rows/S.total, so 237 // a remount (mount() calls load(true) again) must not let an older request 238 // from before the unmount overwrite the fresh one that replaced it. 239 var loadSeq = 0; 240 // The same stale-response guard for loadMyRank() â a slower request from a 241 // game/region that has since been changed away from must not overwrite the 242 // rank card with an answer for the wrong context. 243 var myRankSeq = 0; 244 // Stale-response guard for the TCG rail's three fetches (Task 8) â bumped 245 // on every loadTcgRail() call (including a game switch) so an older 246 // in-flight request can't paint over a newer one. 247 var tcgRailSeq = 0; 248 // Stale-response guard for the Formats tab fetch (Task 8) â bumped on 249 // every loadFormatsTab() call. 250 var formatsSeq = 0; 251 252 // The game rail's own resize listener â destroyed before every re-wire (a 253 // full render() rebuild discards whatever it was wired to) and again in 254 // unmount(), so leaving Leaderboard never leaks a window listener pointed 255 // at detached nodes. 256 var railWire = null; 257 258 // Must match packages/shared/src/types/game.ts GameType exactly â these 259 // strings go straight into the API path. Labels mirror 260 // apps/server/public/landing/leaderboard.js's GAMES table. 261 // 262 // Duplicated in /shared/profile-render.js on purpose â see that file's 263 // top-of-file comment. Keep in sync by hand if either changes. 264 var GAMES = [ 265 { id: 'DICE_THRONE', label: 'Dice Throne' }, 266 { id: 'MTG', label: 'Magic: The Gathering' }, 267 { id: 'POKEMON_TCG', label: 'Pokémon TCG' }, 268 { id: 'LORCANA', label: 'Disney Lorcana' }, 269 { id: 'YU_GI_OH', label: 'Yu-Gi-Oh!' }, 270 { id: 'ONE_PIECE_TCG', label: 'One Piece' }, 271 { id: 'STAR_WARS_UNLIMITED', label: 'SW: Unlimited' }, 272 { id: 'RIFTBOUND', label: 'Riftbound' }, 273 ]; 274 275 // GAMES above carries only {id,label} â every other menu on this page
276 // (region/sort chips) shares that shape. The game rail additionally needs 277 // each game's logo and brand colour, resolved from window.TDTT.game 278 // (game-constants.js) at RENDER time rather than baked into GAMES itself, 279 // so a page that somehow renders before game-constants.js has finished 280 // loading degrades to a blank logo instead of throwing. Same `/m/logos/` + 281 // logoFile path codex.js's own GAMES table uses. /app has no 282 // installed-pack concept, so no tile is ever marked `dim` here (unlike 283 // desktop's game tab strip). 284 function gamesForRail() { 285 var table = window.TDTT && window.TDTT.game && window.TDTT.game.TABLE; 286 return GAMES.map(function (g) { 287 var meta = table && table[g.id]; 288 return { 289 id: g.id, 290 label: g.label, 291 logo: meta ? '/m/logos/' + meta.logoFile : '', 292 tile: meta ? meta.brandVar : '', 293 }; 294 }); 295 } 296 297 // Labels match apps/desktop/src/routes/Leaderboard.tsx's PlayerScopeSelector 298 // verbatim (same scope ids, same wording) â this row is now rendered on the 299 // banner as .chip filters rather than a .seg-slab, per the brief's chip-vs- 300 // slab rule, but the copy itself should read identically on both surfaces. 301 var SCOPES = [ 302 { id: 'ranked', label: 'Ranked · Online' }, 303 { id: 'tournament', label: 'Online Tournaments' }, 304 { id: 'throne_room', label: 'In-Person' }, 305 ]; 306 307 var SORTS = [ 308 { id: 'rating', label: 'Rating' }, 309 { id: 'wins', label: 'Wins' }, 310 { id: 'games', label: 'Games' }, 311 { id: 'winrate', label: 'Win Rate' }, 312 ]; 313 314 // Keys and labels are copied verbatim from the desktop app's SORT_OPTIONS 315 // (apps/desktop/src/components/leaderboard/HeroesView.tsx) so the same 316 // seven-key hero sort reads identically and orders identically (both go 317 // through the same lb.sortHeroes -> packages/shared HeroSortKey) on both 318 // clients. 319 var HERO_SORTS = [ 320 { id: 'winRate', label: 'Win Rate' }, 321 { id: 'pickRate', label: 'Pick Rate' }, 322 { id: 'mostPlayed', label: 'Most Played' }, 323 { id: 'avgDamage', label: 'Avg Damage' }, 324 { id: 'mostWins', label: 'Most Wins' }, 325 { id: 'mostUltimates', label: 'Most Ultimates' }, 326 { id: 'name', label: 'Name' }, 327 ]; 328 329 // Leaderboard Insights rows â copied verbatim from desktop's INSIGHT_ROWS 330 // (HeroesView.tsx). `metric` renders the same secondary line desktop shows 331 // next to the hero name. 332 var HERO_INSIGHT_ROWS = [ 333 { key: 'highestWinRate', label: 'Highest Win Rate', metric: function (h) { return h.winRate + '%'; } }, 334 { key: 'mostPlayed', label: 'Most Played', metric: function (h) { return fmtNum(h.picks) + ' games'; } }, 335 { key: 'mostWins', label: 'Most Wins', metric: function (h) { return fmtNum(h.wins) + ' wins'; } }, 336 { 337 key: 'mostUltimates', 338 label: 'Most Ultimates', 339 metric: function (h) { return fmtNum(h.ultimatesLanded || 0) + ' landed'; }, 340 }, 341 ]; 342 343 // Labels for the Top Hero Trends aside's .seg-slab, matching desktop's 344 // HeroTrendsPanel.tsx WINDOW_LABELS. 345 var TREND_WINDOW_LABELS = { '7d': '7D', '30d': '30D', '90d': '90D', all: 'ALL' }; 346 347 // Per-scope empty-state copy, verbatim from the desktop app 348 // (apps/desktop/src/routes/Leaderboard.tsx) â three scopes are empty for 349 // different reasons and the copy is the only thing that explains which. 350 var SCOPE_EMPTY = { 351 ranked: { title: 'No matches played yet', sub: null }, 352 tournament: { 353 title: 'No online tournament results yet', 354 sub: 'Ranks players by wins across networked (webcam) tournament matches', 355 }, 356 throne_room: { 357 title: 'No in-person results yet', 358 sub: 'Tracks Throne Room tournament and confirmed Play Now results for linked accounts', 359 }, 360 }; 361 362 // currentColor SVG icons for the .tdtt-statrow tiles â never emoji, since 363 // every icon slot in that shared component tints via CSS `color` and an 364 // emoji would ignore it (see _preview.html's stat-row demo comment). 365 var STAT_ICON = { 366 rating: 367 '<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" ' + 368 'stroke-linecap="round" stroke-linejoin="round"><path d="M5 2h6v3.2a3 3 0 0 1-6 0V2Z"/>' + 369 '<path d="M5 3H3.2A1.8 1.8 0 0 0 5 5.4"/><path d="M11 3h1.8A1.8 1.8 0 0 1 11 5.4"/>' + 370 '<line x1="8" y1="8.2" x2="8" y2="11"/><path d="M5.5 14h5"/><path d="M6.3 11h3.4l.6 3H5.7z"/></svg>', 371 games: 372 '<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" ' + 373 'stroke-linecap="round" stroke-linejoin="round"><rect x="2.4" y="2.4" width="11.2" height="11.2" rx="2.2"/>' + 374 '<circle cx="5.6" cy="5.6" r=".9" fill="currentColor" stroke="none"/>' + 375 '<circle cx="10.4" cy="5.6" r=".9" fill="currentColor" stroke="none"/>' + 376 '<circle cx="5.6" cy="10.4" r=".9" fill="currentColor" stroke="none"/>' + 377 '<circle cx="10.4" cy="10.4" r=".9" fill="currentColor" stroke="none"/>' + 378 '<circle cx="8" cy="8" r=".9" fill="currentColor" stroke="none"/></svg>', 379 winrate: 380 '<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" ' + 381 'stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5 6.2 12 13 3.6"/></svg>', 382 }; 383 384 function setState(patch) { 385 for (var k in patch) { 386 if (Object.prototype.hasOwnProperty.call(patch, k)) S[k] = patch[k]; 387 } 388 render(); 389 } 390 391 function esc(s) { 392 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { 393 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 394 }); 395 } 396 397 // T15: a player's avatarUrl is player-supplied and may legitimately be a 398 // same-origin upload path this server's own upload route minted (e.g. 399 // `/uploads/avatars/<name>`), not only an absolute http(s) URL -- 400 // mirroring the server's isSafeAvatarUrl() allow-list 401 // (apps/server/src/http/url.ts). A pre-existing row may still hold a 402 // `javascript:`/`data:`/protocol-relative value, so any src built from it 403 // must pass through this allow-list first. 404 function safeImageUrl(u) { 405 if (typeof u !== 'string') return ''; 406 var t = u.trim(); 407 if (/^https?:\/\//i.test(t)) return t; 408 return /^\/uploads\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp|gif)$/i.test(t) ? t : ''; 409 } 410 411 // Never render `undefined`/`null` for a numeric field â an em dash instead. 412 function numOrDash(v) { 413 return v === null || v === undefined ? 'â' : v; 414 } 415
416 // Request-token guard for a late fetch response (Task 9, Review Focus 5) â 417 // `state` is a per-fetch state object carrying its own `token` counter 418 // (S.decksTab for the grid fetch, S.deckPage for the archetype page's three 419 // fetches). The caller captures `var mine = ++state.token;` before issuing 420 // the request and passes that same value back in here once it resolves. 421 // Pure â no globals, no closure reads beyond the two arguments â so it can 422 // be unit tested directly with plain object literals, no DOM or network. 423 function acceptResponse(state, token) { 424 return !!state && state.token === token; 425 } 426 427 // The key both existing clients (mobile auth-bridge.js, tournament flows) 428 // already use to identify the signed-in player. Wrapped in try/catch like 429 // every other localStorage read in this file (private mode / storage 430 // disabled must degrade to "signed out", not throw). 431 function getPlayerId() { 432 try { 433 return localStorage.getItem('dta_playerId') || null; 434 } catch (e) { 435 return null; 436 } 437 } 438 439 function initialOf(name) { 440 var n = String(name || '').trim(); 441 return n ? n.charAt(0).toUpperCase() : '?'; 442 } 443 444 // Renders an <img> plus a HIDDEN fallback-letter sibling and wires the 445 // error handler in JS (wireAvatarFallback) rather than an inline `onerror` 446 // attribute â an inline attribute would need the initial letter embedded as 447 // a JS string INSIDE an HTML attribute, a mixed-context escape that HTML 448 // decodes before the JS engine sees it. esc() escapes for HTML, not JS, so 449 // a display name starting with `'` would decode back to a bare quote and 450 // terminate the string early, breaking the handler (apps/server/public/ 451 // landing/leaderboard.js:74-84 solved this the same way; not exploitable 452 // here since initialOf() takes one character, but still a real bug â the 453 // fallback letter would silently never render for that name). 454 function avatarHtml(row, extraClass) { 455 var cls = 'tdtt-avatar' + (extraClass ? ' ' + extraClass : ''); 456 var initial = esc(initialOf(row.displayName)); 457 var avatarUrl = safeImageUrl(row.avatarUrl); 458 if (avatarUrl) { 459 return ( 460 '<div class="' + cls + '">' + 461 '<img class="lb-avatar-img" src="' + esc(avatarUrl) + '" alt="">' + 462 '<span class="lb-avatar-init" hidden>' + initial + '</span>' + 463 '</div>' 464 ); 465 } 466 return '<div class="' + cls + '">' + initial + '</div>'; 467 } 468 469 // Wires the error->fallback swap for every avatar <img> under `scope`. Must 470 // be called after any innerHTML write that can contain avatarHtml() output 471 // (the full render() and the search-results dropdown both do). 472 function wireAvatarFallback(scope) { 473 var imgs = scope.querySelectorAll('.lb-avatar-img'); 474 for (var i = 0; i < imgs.length; i++) { 475 wireOneAvatarImg(imgs[i]); 476 } 477 } 478 479 function wireOneAvatarImg(img) { 480 img.addEventListener('error', function () { 481 img.hidden = true; 482 var init = img.nextElementSibling; 483 if (init) init.hidden = false; 484 }); 485 } 486 487 // Every character portrait on either client needs the .jpg -> .png fallback: 488 // 9 of the 44 heroes ship PNG-only. Inline `onerror` rather than a wired 489 // listener because â unlike avatarHtml â nothing user-supplied is embedded in 490 // the attribute, so there is no mixed-context escaping hazard here. 491 function heroPortraitHtml(slug, cls) { 492 var src = '/assets/extracted/characters/' + esc(slug) + '/portrait.jpg'; 493 return ( 494 '<img' + (cls ? ' class="' + cls + '"' : '') + ' src="' + src + '" alt="" ' + 495 'data-fb-retry="jpg2png" data-fb-visibility-hidden>' 496 ); 497 } 498 499 // Thousands separators for the aggregate combat counters, which run to seven 500 // figures. Never renders `NaN` for a missing field. 501 function fmtNum(n) { 502 var v = Number(n); 503 return isFinite(v) ? v.toLocaleString() : 'â'; 504 } 505 506 // ââ Banner (Task 4) âââââââââââââââââââââââââââââââââââââââââââââââââââââ 507 // The scope filter now lives on the banner itself, per the brief, as .chip 508 // buttons rather than the .seg-slab it used to render as â a chip is a 509 // filter (which list you're looking at), a slab segment is a mode switch 510 // (Players/Heroes/Drafts/Bans within that list). Rendered on every view, 511 // not gated to 'players', since the banner is page furniture; clicking a 512 // scope chip while on Heroes/Drafts/Bans still updates S.scope for when the 513 // user returns to Players (setFilter's existing load(true) call is a wasted 514 // fetch in that case, not a broken one â see the task report). 515 function bannerHtml() { 516 var scopeChips = SCOPES.map(function (s) { 517 return ( 518 '<button type="button" class="chip' + (s.id === S.scope ? ' on' : '') + '" data-scope="' + 519 esc(s.id) + '">' + esc(s.label) + '</button>' 520 ); 521 }).join(''); 522 return ( 523 '<div class="tdtt-banner" data-fx="bold" data-variant="colosseum" style="--ac: var(--p-colosseum)">' + 524 '<canvas class="tdtt-banner__fx"></canvas>' + 525 '<span class="tdtt-banner__eyebrow">THE COLOSSEUM</span>' + 526 '<h2 class="tdtt-banner__title">LEADER<span class="tdtt-banner__accent">BOARDS</span></h2>' + 527 '<p class="tdtt-banner__sub">See where you stand across every game, format and hero.</p>' + 528 '<div class="tdtt-banner__actions" id="lb-scopes"><div class="chip-row">' + scopeChips + '</div></div>' + 529 '</div>' 530 ); 531 } 532 533 // ââ Stat tile row (Task 10, 2026-09-22) ââââââââââââââââââââââââââââââââââ 534 // Four tiles, matching the desktop implementation's labels exactly. 535 // 536 // Three are population figures from the server (S.total, S.totals.rankedGames, 537 // and the supported-game count). The fourth â average win rate â is 538 // DELIBERATELY scoped to the players currently listed, and its label says 539 // so. Do not "fix" it into a population aggregate: every completed 1v1 game 540 // writes exactly one WIN and one LOSS, so the population average is exactly 541 // 50% (and lower for pod formats) â a constant that tracks format mix 542 // rather than anything a reader would take it to mean. 543 // 544 // A null population figure renders an em dash, never 0 â unknown and zero 545 // are different claims. 546 function statTileHtml(icon, value, label, accent) { 547 return ( 548 '<div class="tdtt-stat" style="--sec-accent:' + accent + '">' + 549 '<span class="i">' + icon + '</span>' + 550 '<span class="v">' + esc(value) + '</span>' + 551 '<span class="l">' + esc(label) + '</span>' + 552 '<span class="u"></span>' + 553 '</div>' 554 ); 555 } 556 557 function summaryStatsHtml() { 558 if (S.view !== 'players' || S.scope !== 'ranked') return ''; 559 var dash = 'â'; 560 var g = window.TDTT.lbCore.groupThousands; 561 562 var n = S.rows.length; 563 var sumWinRate = 0; 564 for (var i = 0; i < n; i++) sumWinRate += Number(S.rows[i].winRate) || 0; 565 var avgWinRate = n > 0 ? Math.round((sumWinRate / n) * 10) / 10 : null; 566 567 // Task 8: the TCG variant swaps the static "Supported Games" tile for the 568 // dynamic "Active Deck Archetypes" count (a population figure specific to 569 // deck-tracked games â mirrors desktop's tcgLeaderboardTiles()) AND moves 570 // it to the LAST slot, after Avg Win Rate â the two tile orders are not 571 // the same four tiles reordered by one swap, they differ in position too. 572 // `null` (S.tcgRail.decksState !== 'ready') renders an em dash, never 0. 573 if (showsTcgFeatures()) { 574 var archetypes = S.tcgRail.decksState === 'ready' ? S.tcgRail.decks.length : null; 575 return ( 576 '<div class="tdtt-statrow">' + 577 statTileHtml(STAT_ICON.rating, S.total === null ? dash : g(S.total), 'Active Players', 'var(--gold)') + 578 statTileHtml(STAT_ICON.games, S.totals === null ? dash : g(S.totals.rankedGames), 'Ranked Games', 'var(--magenta)') + 579 statTileHtml(STAT_ICON.winrate, avgWinRate === null ? dash : avgWinRate + '%', 'Avg Win Rate · Top ' + S.limit, 'var(--green)') + 580 statTileHtml(STAT_ICON.games, archetypes === null ? dash : String(archetypes), 'Active Deck Archetypes', 'var(--cyan)') + 581 '</div>' 582 ); 583 } 584 585 var supportedGames = Object.keys(window.TDTT.game.TABLE).length; 586 587 return ( 588 '<div class="tdtt-statrow">' + 589 statTileHtml(STAT_ICON.rating, S.total === null ? dash : g(S.total), 'Active Players', 'var(--gold)') + 590 statTileHtml(STAT_ICON.games, S.totals === null ? dash : g(S.totals.rankedGames), 'Ranked Games', 'var(--magenta)') + 591 statTileHtml(STAT_ICON.games, String(supportedGames), 'Supported Games', 'var(--cyan)') + 592 statTileHtml(STAT_ICON.winrate, avgWinRate === null ? dash : avgWinRate + '%', 'Avg Win Rate · Top ' + S.limit, 'var(--green)') + 593 '</div>' 594 ); 595 } 596 597 // Dice Throne only, and this is CORRECTNESS not taste: the server derives 598 // topCharacters by joining `characters` on game_players.character_id, and 599 // TCG rows point at synthetic per-GameType "system" characters, so on any 600 // other game the column renders one meaningless tile per player. See 601 // docs/superpowers/specs/2026-09-22-leaderboard-and-profile-design.md 1.3. 602 function showsTopCharacters() { 603 return S.game === 'DICE_THRONE'; 604 } 605 606 // ââ Ranked table (the podium's replacement) ââââââââââââââââââââââââââââââââ 607 // Columns per the mockup: #, Player, {RATING_LABEL}, W-L, Win Rate, Games. 608 // `.tdtt-table__num` goes on the <th> here too, not just the data <td>s â 609 // under table-layout:fixed the FIRST row sets column widths, so a class 610 // that only lands on data cells never takes effect. 611 function tableHeadHtml() { 612 return ( 613 '<thead><tr>' + 614 '<th></th>' + 615 '<th>Player</th>' + 616 '<th class="tdtt-table__num">' + esc(window.TDTT.game.RATING_LABEL) + '</th>' + 617 '<th class="tdtt-table__num">W-L</th>' + 618 '<th class="tdtt-table__num">Win Rate</th>' + 619 '<th class="tdtt-table__num">Games</th>' + 620 (showsTcgFeatures() ? '<th>Favorite Deck</th>' : '') + 621 (showsTopCharacters() ? '<th>Top 5 Heroes</th>' : '') + 622 '</tr></thead>' 623 ); 624 } 625 626 // ââ Favorite Deck column (Task 8) ââââââââââââââââââââââââââââââââââââââââ
627 // Identity chips reuse `.tdtt-idchip` (shared/controls.css) â the SAME 628 // primitive the Decks tab (profile-decks.js) already renders through, not 629 // a page-local copy. Colour comes ONLY from window.TDTT.decks.identityColor 630 // (a fixed per-game hex table), still escaped per the batch ruling; a 631 // value outside that table (or none) falls back to the `.is-neutral` 632 // variant instead of an inline colour. 633 function deckIdentityChipsHtml(gameType, identity) { 634 var html = ''; 635 for (var i = 0; i < identity.length; i++) { 636 var v = identity[i]; 637 var hex = window.TDTT.decks.identityColor(gameType, v); 638 var cls = 'tdtt-idchip' + (hex ? '' : ' is-neutral'); 639 var style = hex ? ' style="--id-c:' + esc(hex) + '"' : ''; 640 html += '<span class="' + cls + '"' + style + '>' + esc(window.TDTT.decks.identityLabel(gameType, v)) + '</span>'; 641 } 642 return html; 643 } 644 645 // `â` when the player has no public favorite deck at all; identity chips 646 // plus the deck name when it has one and the name is public; chips only 647 // (no name) for a favorite deck whose bucket isn't public yet (fewer than 648 // DECK_LIMITS.publicMinPlayers pilots) â `favoriteDeck.name` is null in 649 // that case even though `identity` still is (server contract, Task 2/3). 650 // A click opens the deck via the delegated `[data-deck-key]` handler in 651 // wireEvents() when `key` is set â mirrors desktop's FavoriteDeckCell. 652 function favoriteDeckCellHtml(gameType, favoriteDeck) { 653 if (!favoriteDeck) return 'â'; 654 var inner = 655 '<span class="lb-fav-deck">' + deckIdentityChipsHtml(gameType, favoriteDeck.identity) + 656 (favoriteDeck.name ? '<span class="lb-fav-deck-name">' + esc(favoriteDeck.name) + '</span>' : '') + 657 '</span>'; 658 if (!favoriteDeck.key) return inner; 659 return ( 660 '<button type="button" class="lb-fav-deck-btn" data-deck-key="' + esc(favoriteDeck.key) + '">' + 661 inner + '</button>' 662 ); 663 } 664 665 // Unicode medal glyphs for the top three ranks, matching the shared design 666 // reference in _preview.html. Every other rank shows its plain number. 667 var MEDAL_GLYPH = { 1: '🥇', 2: '🥈', 3: '🥉' }; 668 669 // `.tdtt-table__name` goes on a wrapper INSIDE the <td>, never on the <td> 670 // itself â it sets display:block, which would remove the cell from table 671 // layout. The reduced (anonymous) projection has no wins/losses/gamesPlayed 672 // at all, per isReducedRow() â those three cells render an em dash rather 673 // than "undefined-undefined". 674 function tableRowHtml(p) { 675 var rank = p.rank; 676 var medal = MEDAL_GLYPH[rank]; 677 var reduced = p.wins === null || p.wins === undefined; 678 var record = reduced ? 'â' : numOrDash(p.wins) + 'â' + numOrDash(p.losses); 679 var wrClass = ''; 680 var wrText = 'â'; 681 if (!reduced && p.winRate !== null && p.winRate !== undefined) { 682 wrClass = window.TDTT.bands.winRateBand(p.wins, p.losses); 683 wrText = p.winRate + '%'; 684 } 685 var games = reduced ? 'â' : numOrDash(p.gamesPlayed); 686 return ( 687 '<tr class="tdtt-table__row' + (medal ? ' tdtt-table__medal' : '') + '"' + 688 ' data-pid="' + esc(p.playerId) + '" data-rank="' + numOrDash(rank) + '"' + 689 '>' + 690 '<td>' + (medal || numOrDash(rank)) + '</td>' + 691 '<td><div class="tdtt-table__player">' + avatarHtml(p, '') + 692 '<span class="tdtt-table__name">' + esc(p.displayName) + '</span></div></td>' + 693 '<td class="tdtt-table__num">' + numOrDash(p.skillRating) + '</td>' + 694 '<td class="tdtt-table__num">' + esc(record) + '</td>' + 695 '<td class="tdtt-table__num' + (wrClass ? ' rec ' + wrClass : '') + '">' + esc(wrText) + '</td>' + 696 '<td class="tdtt-table__num">' + games + '</td>' + 697 (showsTcgFeatures() 698 ? '<td>' + favoriteDeckCellHtml(S.game, p.favoriteDeck || null) + '</td>' 699 : '') + 700 (showsTopCharacters() 701 ? '<td>' + window.TDTT.profileRender.heroStripHtml(p.topCharacters, true) + '</td>' 702 : '') + 703 '</tr>' 704 ); 705 } 706 707 // total is null for the tournament and in-person scopes (unpaged endpoints) 708 // â treated as "unknown", never as 0. footerHtml() falls back to a plain 709 // "N loaded" readout with no pager; pagerHtml() below independently refuses 710 // to render for the same reason, since a pager built from null total would 711 // read "Page 1 of NaN". 712 function footerHtml() { 713 var readout = window.TDTT.lbCore.pagerSummary(S.offset, S.rows.length, S.total); 714 if (readout === null) readout = S.rows.length + ' loaded'; 715 return '<div class="lb-footer"><span class="lb-readout">' + esc(readout) + '</span></div>' + pagerHtml(); 716 } 717 718 // Renders `.tdtt-pager` ONLY when S.total is a number and there is more 719 // than one page â never from a null total (the tournament/in-person 720 // scopes), which would otherwise render "Page 1 of NaN". Windowing and the 721 // ellipsis ('gap') come from the shared TypeScript helper (leaderboard-core.js) 722 // so /app and desktop cannot disagree about which page buttons exist. 723 function pagerHtml() { 724 if (S.total === null) return ''; 725 var totalPages = Math.max(1, Math.ceil(S.total / S.limit)); 726 if (totalPages <= 1) return ''; 727 var current = Math.floor(S.offset / S.limit) + 1; 728 var pages = window.TDTT.lbCore.pagerPages(current, totalPages); 729 var html = '<div class="tdtt-pager">'; 730 html += 731 '<button type="button" class="tdtt-pager__page" data-page="' + (current - 1) + '"' + 732 (current <= 1 ? ' disabled' : '') + 733 '>«</button>'; 734 html += pages 735 .map(function (n) { 736 if (n === 'gap') return '<span class="tdtt-pager__gap" aria-hidden="true">â¦</span>'; 737 return ( 738 '<button type="button" class="tdtt-pager__page' + (n === current ? ' on' : '') + '" data-page="' + 739 n + '">' + n + '</button>' 740 ); 741 }) 742 .join(''); 743 html += 744 '<button type="button" class="tdtt-pager__page" data-page="' + (current + 1) + '"' + 745 (current >= totalPages ? ' disabled' : '') + 746 '>»</button>'; 747 html += '</div>'; 748 return html; 749 } 750 751 function goToPage(n) { 752 if (!n || n < 1) return; 753 S.offset = (n - 1) * S.limit; 754 load(false); 755 } 756 757 function loadingHtml() { 758 return '<div class="lb-status lb-loading">Loadingâ¦</div>'; 759 } 760 761 // The selected format's `{id, name}` from window.TDTT.decks.deckFormatOptions 762 // (decks-core.js), or null when no format is selected or the scope isn't 763 // ranked (in-format ranking is a ranked-only mode â see setFilter()). 764 // Drives the empty-format message and the "Ranked by record inâ¦" caption 765 // below, matching desktop's `format` prop on PlayersTable exactly. 766 function currentFormatMeta() { 767 if (S.scope !== 'ranked' || !S.format) return null; 768 var opts = window.TDTT.decks ? window.TDTT.decks.deckFormatOptions(S.game) : []; 769 for (var i = 0; i < opts.length; i++) { 770 if (opts[i].id === S.format) return opts[i]; 771 } 772 return null; 773 } 774 775 function emptyHtml() { 776 var fmt = currentFormatMeta(); 777 if (fmt) { 778 return ( 779 '<div class="lb-status lb-empty"><p class="lb-empty-title">' + 780 esc('No player has 5 games in ' + fmt.name + ' yet') + '</p></div>' 781 ); 782 } 783 var copy = SCOPE_EMPTY[S.scope] || SCOPE_EMPTY.ranked; 784 var sub = copy.sub ? '<p class="lb-empty-sub">' + esc(copy.sub) + '</p>' : ''; 785 return ( 786 '<div class="lb-status lb-empty"><p class="lb-empty-title">' + esc(copy.title) + '</p>' + sub + '</div>' 787 ); 788 } 789 790 function errorHtml() { 791 return ( 792 '<div class="lb-status lb-error"><p class="lb-error-title">Couldnât load the leaderboard</p>' + 793 '<p class="lb-error-detail">' + esc(S.error) + '</p>' + 794 '<button type="button" class="lb-btn" id="lb-retry">Retry</button></div>' 795 ); 796 } 797 798 function resultsHtml() { 799 // The three-column podium is retired (2026-09-10) â the top three ranks 800 // are now medal rows inside the one ranked table below, not a separate 801 // component. See .superpowers/sdd/2026-09-10-ui-unification-phase-2a-leaderboards. 802 var html = ''; 803 var fmt = currentFormatMeta(); 804 if (fmt && S.rows.length) { 805 html += '<p class="lb-format-caption">' + esc('Ranked by record in ' + fmt.name + ' (min 5 games)') + '</p>'; 806 } 807 if (S.rows.length) { 808 html += 809 '<div class="lb-list"><table class="tdtt-table">' + 810 tableHeadHtml() + 811 '<tbody>' + S.rows.map(tableRowHtml).join('') + '</tbody>' + 812 '</table></div>'; 813 } 814 html += footerHtml(); 815 return html; 816 } 817 818 function listHtml() { 819 if (S.error) return errorHtml(); 820 if (S.loading && S.rows.length === 0) return loadingHtml(); 821 if (!S.loading && S.rows.length === 0) return emptyHtml(); 822 return resultsHtml(); 823 } 824 825 function regionOptionsHtml() { 826 var opts = '<option value="">All Regions</option>'; 827 if (!regionGroups) return opts; 828 for (var i = 0; i < regionGroups.length; i++) { 829 var g = regionGroups[i]; 830 opts += '<optgroup label="' + esc(g.group) + '">'; 831 for (var j = 0; j < g.regions.length; j++) { 832 var r = g.regions[j]; 833 opts += '<option value="' + esc(r) + '"' + (r === S.region ? ' selected' : '') + '>
833' + esc(r) + '</option>'; 834 } 835 opts += '</optgroup>'; 836 } 837 return opts; 838 } 839 840 // "Show my place" â only meaningful against the `ranked` scope: the rank 841 // endpoint always computes a player's position from completed ranked games 842 // (see apps/server/src/routes/leaderboard.ts `/players/:playerId/rank`), 843 // which has no relationship to the tournament/throne_room lists' own 844 // locally-numbered rows. Absent entirely when signed out, per the brief â 845 // not present-and-failing. 846 function myPlaceHtml() { 847 if (S.scope !== 'ranked' || !getPlayerId()) return ''; 848 var loading = S.myPlaceStatus === 'loading'; 849 var msg = 850 !loading && S.myPlaceStatus 851 ? '<div class="lb-signin-banner">' + esc(S.myPlaceStatus) + '</div>' 852 : ''; 853 return ( 854 '<div class="lb-filterrow">' + 855 '<button type="button" class="chip" id="lb-myplace"' + 856 (loading ? ' disabled' : '') + 857 '>' + 858 (loading ? 'Finding your placeâ¦' : 'Show my place') + 859 '</button>' + 860 '</div>' + 861 msg 862 ); 863 } 864 865 // Finds the DOM node for `rank` in the just-rendered table â every rank, 866 // including the medal top three, carries `data-rank` on its 867 // `.tdtt-table__row` â and highlights + scrolls to it. Must run AFTER the 868 // render that placed that rank on screen. Reads the attribute rather than 869 // cell text because the top three render a medal glyph, not the numeral, 870 // in that cell. 871 function scrollToRank(rank) { 872 if (!root) return; 873 var rows = root.querySelectorAll('.tdtt-table__row[data-rank]'); 874 for (var i = 0; i < rows.length; i++) { 875 if (Number(rows[i].getAttribute('data-rank')) === rank) { 876 rows[i].classList.add('me'); 877 if (rows[i].scrollIntoView) rows[i].scrollIntoView({ block: 'center' }); 878 return; 879 } 880 } 881 } 882 883 // Step 1 of the brief: read the signed-in id, ask the rank endpoint with the 884 // CURRENT region/sort (a different ordering answers with the wrong row), 885 // then either show the unranked message and stop, or jump to the page that 886 // contains the rank, load ONLY that page (not accumulated via Load More), 887 // and highlight the row. 888 function showMyPlace() { 889 var pid = getPlayerId(); 890 if (!pid) return; 891 setState({ myPlaceStatus: 'loading' }); 892 // Shares loadSeq with load(): this fetch chain ends up mutating the same 893 // S.offset/S.rows/S.total, so a remount (or a plain load() started after 894 // this began) must invalidate it too, not just a second showMyPlace() call. 895 var mine = ++loadSeq; 896 window.TDTT.lb 897 .playerRank({ game: S.game, playerId: pid, region: S.region, sort: S.sort }) 898 .then(function (r) { 899 if (mine !== loadSeq) return; // stale â superseded by a newer load/showMyPlace 900 var rank = r && r.rank; 901 if (rank === null || rank === undefined) { 902 setState({ myPlaceStatus: "You're unranked in this game" }); 903 return; 904 } 905 var pageOffset = Math.floor((rank - 1) / S.limit) * S.limit; 906 S.offset = pageOffset; 907 S.rows = []; 908 setState({ loading: true, error: null, myPlaceStatus: null }); 909 return window.TDTT.lb 910 .players({ 911 game: S.game, 912 scope: S.scope, 913 region: S.region, 914 sort: S.sort, 915 limit: S.limit, 916 offset: pageOffset, 917 paged: true, 918 }) 919 .then(function (res) { 920 if (mine !== loadSeq) return; 921 setState({ rows: res.rows, total: res.total, totals: res.totals, loading: false }); 922 scrollToRank(rank); 923 }) 924 .catch(function (e) { 925 if (mine !== loadSeq) return; 926 // The rank WAS found â this second fetch (the actual page) is what 927 // broke, after loading:true/rows:[] already went into state above. 928 // Land in the same recoverable state any other failed load uses 929 // (loading cleared, error set) so the existing error UI + Retry 930 // renders, instead of leaving listHtml() stuck on loadingHtml() 931 // forever with no way out but changing a filter. Caught HERE, not 932 // by the outer .catch below, so it can't be confused with a 933 // playerRank failure (which never touched loading/rows). 934 setState({ loading: false, error: (e && e.message) || 'Failed to load that page of the leaderboard' }); 935 }); 936 }) 937 .catch(function (e) { 938 if (mine !== loadSeq) return; 939 // playerRank() itself failed â nothing in the list was touched, so 940 // this is reported through myPlaceStatus (the banner), not S.error. 941 var msg = 942 e && e.status === 401 ? 'Sign in to see your rank.' : (e && e.message) || 'Could not find your rank'; 943 setState({ myPlaceStatus: msg }); 944 }); 945 } 946 947 // ââ "Your Rank" (main column, per E4.1) ââââââââââââââââââââââââââââââââââ 948 // Fetches the signed-in player's position on the canonical Ranked · Online 949 // board for the CURRENTLY VIEWED game/region. Deliberately NOT tied to 950 // S.sort or S.scope the way showMyPlace() above is: "Your Rank" reads as a 951 // single stable number everywhere else in the app (PlayerRating, the 952 // profile drill-down), and letting it follow whatever column the TABLE 953 // happens to be sorted by (Wins, Games, ...) would make it jump around for 954 // a reason that has nothing to do with rank. It always asks with 955 // sort:'rating'. Tournament/In-Person scopes have no relationship to this 956 // endpoint (it only ever computes the ranked/online ordering), so the card 957 // does not react to scope changes either. 958 // 959 // No playerId in localStorage renders a sign-in prompt, never a rank of 0 960 // or a blank card â same rule the brief applies to the rank number itself. 961 function loadMyRank() { 962 var pid = getPlayerId(); 963 if (!pid) { 964 setState({ myRank: { status: 'signin' } }); 965 return; 966 } 967 setState({ myRank: { status: 'loading' } }); 968 var mine = ++myRankSeq; 969 window.TDTT.lb 970 .playerRank({ game: S.game, playerId: pid, region: S.region, sort: 'rating' }) 971 .then(function (r) { 972 if (mine !== myRankSeq) return; // stale â game/region changed since this began 973 var rank = r && r.rank; 974 if (rank === null || rank === undefined) { 975 setState({ myRank: { status: 'unranked' } }); 976 } else { 977 setState({ myRank: { status: 'ready', rank: rank, total: r.total } }); 978 } 979 }) 980 .catch(function (e) { 981 if (mine !== myRankSeq) return; 982 setState({ myRank: { status: e && e.status === 401 ? 'signin' : 'error' } }); 983 }); 984 } 985 986 // Reuses a STAT_ICON svg at the right rail's smaller 14px size (the mockup
987 // reference in _preview.html's .tdtt-aside demo uses 14x14 icons, not the 988 // stat row's 18x18) rather than drawing a second icon set. 989 function asideIcon(svg) { 990 return svg.replace('width="18" height="18"', 'width="14" height="14"'); 991 } 992 993 function yourRankCardHtml() { 994 var mr = S.myRank; 995 var body; 996 if (!mr || mr.status === 'signin') { 997 body = '<p class="lb-aside-copy">Sign in to see where you rank on the Ranked · Online leaderboard.</p>'; 998 } else if (mr.status === 'loading') { 999 body = '<p class="lb-aside-copy">Finding your rankâ¦</p>'; 1000 } else if (mr.status === 'unranked') { 1001 body = '<p class="lb-aside-copy">Play a ranked online match to get on the board.</p>'; 1002 } else if (mr.status === 'error') { 1003 body = '<p class="lb-aside-copy">Couldnât load your rank right now.</p>'; 1004 } else { 1005 body = 1006 '<div class="lb-myrank">' + 1007 '<span class="lb-myrank__num">#' + mr.rank + '</span>' + 1008 (mr.total ? '<span class="lb-myrank__of">of ' + fmtNum(mr.total) + ' ranked players</span>' : '') + 1009 '</div>'; 1010 } 1011 return ( 1012 '<div class="tdtt-asidecard" style="--ac: var(--p-colosseum)">' + 1013 '<div class="tdtt-asidecard__head"><span class="tdtt-asidecard__icon">' + 1014 asideIcon(STAT_ICON.rating) + '</span>Your Rank</div>' + 1015 body + 1016 '</div>' 1017 ); 1018 } 1019 1020 // ââ "Leaderboard Info" (now a main-column card â see rankInfoRowHtml()) ââ 1021 // Static explainer, matching the mockup's card shape. The mockup's fourth 1022 // item (Season Rewards) is OMITTED, not worded around â see the "no season 1023 // card" comment above rankInfoRowHtml() below. 1024 var LEADERBOARD_INFO_ITEMS = null; // built lazily so RATING_LABEL is read once game-constants.js is loaded 1025 1026 function leaderboardInfoItems() { 1027 if (LEADERBOARD_INFO_ITEMS) return LEADERBOARD_INFO_ITEMS; 1028 var label = window.TDTT.game.RATING_LABEL; 1029 LEADERBOARD_INFO_ITEMS = [ 1030 { 1031 icon: STAT_ICON.rating, 1032 title: label, 1033 body: 1034 'Every ranked player has an ' + label + ' rating. It rises with wins and falls with ' + 1035 'losses, weighted by the strength of your opponent.', 1036 }, 1037 { 1038 icon: STAT_ICON.games, 1039 title: 'Ranked matches', 1040 body: 1041 'Only completed Ranked · Online webcam matches count toward your ' + label + 1042 '. Tournaments and in-person play are tracked on their own leaderboards.', 1043 }, 1044 { 1045 icon: STAT_ICON.winrate, 1046 title: 'Fair play', 1047 body: 'Rankings reflect real matches played honestly. Suspected cheating is reviewed and can result in a ban.', 1048 }, 1049 ]; 1050 return LEADERBOARD_INFO_ITEMS; 1051 } 1052 1053 function leaderboardInfoCardHtml() { 1054 var itemsHtml = leaderboardInfoItems().map(function (it) { 1055 return ( 1056 '<div class="lb-info-item">' + 1057 '<span class="lb-info-item__icon">' + asideIcon(it.icon) + '</span>' + 1058 '<div class="lb-info-item__body"><strong>' + esc(it.title) + '</strong><p>' + esc(it.body) + '</p></div>' + 1059 '</div>' 1060 ); 1061 }).join(''); 1062 return ( 1063 '<div class="tdtt-asidecard" style="--ac: var(--p-community)">' + 1064 '<div class="tdtt-asidecard__head"><span class="tdtt-asidecard__icon">' + 1065 asideIcon(STAT_ICON.games) + '</span>Leaderboard Info</div>' + 1066 '<div class="lb-info-list">' + itemsHtml + '</div>' + 1067 '</div>' 1068 ); 1069 } 1070 1071 // No Season card: the mockup pairs Your Rank / Leaderboard Info with a 1072 // Season card showing a live countdown ("12d 4h 32m") and a Season Rewards 1073 // tier list. There is no season service anywhere in this codebase â no 1074 // season end date, no rewards concept, nothing to compute a countdown 1075 // from. A hardcoded date would be wrong the moment real time passed it, 1076 // and would be a lie the page told every visitor from the day it shipped. 1077 // If a season service is ever built, ITS card goes here â this is not a 1078 // placeholder to "finish" by inventing a date. 1079 // 1080 // E4.1: this no longer renders a `.tdtt-aside` â the right rail is now
1081 // shell chrome (Forge Feed / Your Stats / Friends / Advertisement, see 1082 // app-shell.js), identical on every page, and a shared shell rail has no 1083 // way to know these two cards are leaderboard-specific. Both cards are 1084 // real leaderboard CONTENT (Your Rank is "the most useful thing on this 1085 // page" per the brief), so they move into the MAIN column instead of 1086 // disappearing â a two-card row using the same `.tdtt-asidecard` shell 1087 // (that class is a general bordered-card primitive, not scoped to 1088 // `.tdtt-aside`), styled by `.lb-rankinfo-row` below. All four of 1089 // yourRankCardHtml()'s states (signin/loading/unranked/error/ready) are 1090 // unchanged. 1091 function rankInfoRowHtml() { 1092 return '<div class="lb-rankinfo-row">' + yourRankCardHtml() + leaderboardInfoCardHtml() + '</div>'; 1093 } 1094 1095 // ââ Task 9: player drill-down panel ââââââââââââââââââââââââââââââââââââââââ 1096 1097 // recordText/gameLabel/deriveMainGame/statCellHtml/surfaceSplitCellHtml/ 1098 // surfaceDetailCellHtml/gameRowHtml/gameAccordionHtml/profileContentHtml 1099 // moved to /shared/profile-render.js (Task 6); this file reaches that 1100 // module in exactly one place, the contentHtml() call below. 1101 1102 // leaderboard-data.js's get() attaches `.status` for every non-ok response 1103 // (401 and everything else, including 404) â branch on that structurally 1104 // rather than matching the error message text. 1105 function classifyProfileError(e) { 1106 if (e && e.status === 401) return 'signin'; 1107 if (e && e.status === 404) return 'notfound'; 1108 return 'generic'; 1109 } 1110 1111 function profileSigninHtml() { 1112 return ( 1113 '<div class="lb-status lb-empty"><p class="lb-empty-title">Sign in to view this profile</p>' + 1114 '<p class="lb-empty-sub"><a href="/m">Sign in on the web companion</a></p></div>' 1115 ); 1116 } 1117 1118 function profileNotFoundHtml() { 1119 return '<div class="lb-status lb-empty"><p class="lb-empty-title">Profile not available</p></div>'; 1120 } 1121 1122 function profileGenericErrorHtml() { 1123 return ( 1124 '<div class="lb-status lb-error"><p class="lb-error-title">Couldnât load this profile</p>' + 1125 '<p class="lb-error-detail">' + esc(S.profileErrorMessage) + '</p>' + 1126 '<button type="button" class="lb-btn" id="lb-profile-retry">Retry</button></div>' 1127 ); 1128 } 1129 1130 function profilePanelHtml() { 1131 if (!S.openPlayerId) return ''; 1132 var body; 1133 if (S.profileErrorKind === 'signin') body = profileSigninHtml(); 1134 else if (S.profileErrorKind === 'notfound') body = profileNotFoundHtml(); 1135 else if (S.profileErrorKind === 'generic') body = profileGenericErrorHtml(); 1136 else if (S.profileLoading || !S.profile) body = loadingHtml(); 1137 else body = window.TDTT.profileRender.contentHtml(S.profile, S.profileOpenGame); 1138 1139 return ( 1140 '<div class="lb-profile-overlay" id="lb-profile-overlay">' + 1141 '<div class="lb-profile-panel" role="dialog" aria-modal="true" aria-label="Player profile">' + 1142 '<div class="lb-profile-panel__head">' + 1143 '<button type="button" class="lb-profile-viewfull" id="lb-profile-viewfull" data-pid="' + 1144 esc(S.openPlayerId) + '">View Full Profile →</button>' + 1145 '<button type="button" class="lb-profile-close" id="lb-profile-close" aria-label="Close">×</button>' + 1146 '</div>' + 1147 body + 1148 '</div></div>' 1149 ); 1150 } 1151 1152 // Phase 3, Task 2 -- the drill-down panel stays a peek (profileContentHtml 1153 // above is untouched); this is the ONE way out to the full 1154 // /app/profile?player=<id> page. `id` here is the raw player id read back 1155 // off the button's data-pid attribute (already HTML-attribute-escaped by 1156 // esc() in profilePanelHtml() above, for THAT context) -- encodeURIComponent 1157 // is a separate job, done here, for the query-string context. Both are 1158 // required; neither substitutes for the other. 1159 function viewFullProfile(id) { 1160 if (!id) return; 1161 if (window.TDTTRouter) window.TDTTRouter.go('profile', '?player=' + encodeURIComponent(id)); 1162 } 1163 1164 // Opens (or re-targets) the drill-down panel and fetches the profile. A 1165 // request id guards against a slow response for a player the user has since 1166 // navigated away from (opened someone else, or closed the panel) painting 1167 // over whatever is on screen now. 1168 function openPlayerProfile(playerId) { 1169 if (!playerId) return; 1170 var seq = ++profileSeq; 1171 setState({ 1172 openPlayerId: playerId, 1173 profile: null, 1174 profileLoading: true, 1175 profileErrorKind: null, 1176 profileErrorMessage: null, 1177 profileOpenGame: null, 1178 }); 1179 window.TDTT.lb 1180 .profile(playerId) 1181 .then(function (p) { 1182 if (seq !== profileSeq) return; // stale â superseded by a newer open/close 1183 setState({ profile: p, profileLoading: false }); 1184 }) 1185 .catch(function (e) { 1186 if (seq !== profileSeq) return; 1187 setState({ 1188 profileLoading: false, 1189 profileErrorKind: classifyProfileError(e), 1190 profileErrorMessage: (e && e.message) || 'Failed to load profile', 1191 }); 1192 }); 1193 } 1194 1195 function closePlayerProfile() { 1196 profileSeq++; // invalidate any in-flight request for the panel that's closing 1197 setState({ 1198 openPlayerId: null, 1199 profile: null, 1200 profileLoading: false, 1201 profileErrorKind: null, 1202 profileErrorMessage: null, 1203 profileOpenGame: null, 1204 }); 1205 } 1206 1207 // Clicking anywhere in a player row opens the profile ONLY when the click 1208 // actually landed on the avatar or the name (per the brief) â not e.g. a 1209 // rating/record/win-rate/games cell. 1210 function wirePlayerOpenTriggers(scope) { 1211 // Scoped to the table rows, not a bare `[data-pid]` â the search-hit 1212 // buttons (`.lb-hit`) also carry a `data-pid` and wire their own click
1213 // handler directly in renderSearchResults(); a generic selector here 1214 // would double-wire them if a render() ever ran while the dropdown's 1215 // results were still in the DOM. 1216 var containers = scope.querySelectorAll('.tdtt-table__row[data-pid]'); 1217 for (var i = 0; i < containers.length; i++) { 1218 containers[i].addEventListener('click', function (e) { 1219 var trigger = e.target.closest ? e.target.closest('.tdtt-avatar, .tdtt-table__name') : null; 1220 if (!trigger) return; 1221 openPlayerProfile(e.currentTarget.getAttribute('data-pid')); 1222 }); 1223 } 1224 } 1225 1226 // ââ Heroes landing (Task 9) âââââââââââââââââââââââââââââââââââââââââââââââââ 1227 // Per-hero stats exist for Dice Throne only: every other game's 1228 // game_players.character_id is a synthetic system row, so /characters answers 1229 // with one meaningless entry. Both existing clients gate on this the same way 1230 // (leaderboard.html's isDiceThrone(), the desktop's isDiceThrone prop). 1231 function isHeroGame() { 1232 return S.game === 'DICE_THRONE'; 1233 } 1234 1235 // Gate for every Task 8 TCG-only addition (format select, Favorite Deck 1236 // column, Active Deck Archetypes tile, insights rail, the Formats tab 1237 // itself): every game except Dice Throne. Mirrors desktop's 1238 // showsTcgLeaderboardFeatures() â the two must never disagree, since 1239 // Dice Throne has neither decks nor `?format=`-aware ranking on the 1240 // server. 1241 function showsTcgFeatures() { 1242 return !isHeroGame(); 1243 } 1244 1245 function heroRecordHtml(wins, losses) { 1246 return ( 1247 '<span class="tdtt-record"><span class="w">' + numOrDash(wins) + '</span>' + 1248 '<span class="sep">â</span><span class="l">' + numOrDash(losses) + '</span></span>' 1249 ); 1250 } 1251 1252 // One tile in the Heroes landing card grid â same fields as desktop's 1253 // HeroCard.tsx (rank in the CURRENT sort, portrait, win-rate badge coloured 1254 // by winRateBand, name, record, games, a pick-rate bar scaled against the 1255 // largest `picks` in the WHOLE set, not against 100%, so the comparison 1256 // across a 44-hero roster stays legible). `data-hero-slug` is what the click 1257 // delegate in wireHeroGridTriggers() reads to open the hero page. 1258 function heroCardHtml(h, rank, maxPicks) { 1259 var band = window.TDTT.bands.winRateBand(h.wins, h.losses); 1260 var width = maxPicks > 0 ? Math.round((h.picks / maxPicks) * 100) : 0; 1261 return ( 1262 '<button type="button" class="tdtt-cardtile lb-herotile" data-hero-slug="' + 1263 esc(h.characterSlug) + '">' + 1264 '<div class="lb-herotile-media">' + heroPortraitHtml(h.characterSlug, 'lb-herotile-img') + '</div>' + 1265 '<span class="lb-herotile-rank">#' + rank + '</span>' + 1266 '<span class="lb-herotile-wr ' + band + '">' + h.winRate + '%</span>' + 1267 '<div class="lb-herotile-body">' + 1268 '<p class="lb-herotile-name">' + esc(h.characterName) + '</p>' + 1269 '<div class="lb-herotile-row">' + heroRecordHtml(h.wins, h.losses) + 1270 '<span class="lb-herotile-games">' + fmtNum(h.picks) + ' games</span></div>' + 1271 '<span class="lb-herotile-bar"><span style="width:' + width + '%"></span></span>' + 1272 '</div>' + 1273 '</button>' 1274 ); 1275 } 1276 1277 // Insight metrics are computed by the shared lb.heroInsights() derivation â 1278 // this file only supplies the label + how to render each hero's secondary 1279 // line, copied verbatim from desktop's INSIGHT_ROWS (see HERO_INSIGHT_ROWS). 1280 function heroInsightsCardHtml() { 1281 var insights = window.TDTT.lb.heroInsights(S.heroes); 1282 var rows = ''; 1283 for (var i = 0; i < HERO_INSIGHT_ROWS.length; i++) { 1284 var def = HERO_INSIGHT_ROWS[i]; 1285 var h = insights[def.key]; 1286 if (!h) continue; 1287 rows += 1288 '<div class="lb-insight-row"><span class="lb-insight-label">' + esc(def.label) + '</span>' + 1289 '<span class="lb-insight-val">' + esc(h.characterName) + 1290 ' <span class="lb-insight-metric">· ' + esc(def.metric(h)) + '</span></span></div>'; 1291 } 1292 return ( 1293 '<div class="tdtt-asidecard" style="--ac: var(--p-colosseum)">' + 1294 '<div class="tdtt-asidecard__head"><span class="tdtt-asidecard__icon">' + 1295 asideIcon(STAT_ICON.winrate) + '</span>Leaderboard Insights</div>' + 1296 '<div class="lb-insight-list">' + 1297 (rows || '<p class="lb-aside-copy">Not enough games yet.</p>') + 1298 '</div></div>' 1299 ); 1300 } 1301 1302 // Top Hero Trends â the aside's OWN window-scoped mini leaderboard (see 1303 // S.heroTrend and loadHeroTrend()). heroTrendRows() already sorts, filters 1304 // to HERO_MIN_GAMES and computes the â²/â¼ delta against the PREVIOUS window; 1305 // this only renders what it returns. Matches desktop's HeroTrendsPanel.tsx â 1306 // plain rows, no click-through (that panel doesn't link to the hero page 1307 // either). 1308 function heroTrendCardHtml() { 1309 var windows = window.TDTT.lbCore.STAT_WINDOWS; 1310 var tabs = ''; 1311 for (var w = 0; w < windows.length; w++) { 1312 var win = windows[w]; 1313 tabs += 1314 '<button type="button" class="t' + (win === S.heroTrend.window ? ' on' : '') + 1315 '" data-trend-window="' + esc(win) + '">' + esc(TREND_WINDOW_LABELS[win] || win) + '</button>'; 1316 } 1317 1318 var body; 1319 if (S.heroTrend.loading) {
1320 body = '<p class="lb-aside-copy lb-center">Loading…</p>'; 1321 } else { 1322 var list = (S.heroTrend.data && S.heroTrend.data.characters) || []; 1323 var rows = window.TDTT.lb.heroTrendRows(list, 5); 1324 if (!rows.length) { 1325 body = '<p class="lb-aside-copy lb-center">Not enough games in this window</p>'; 1326 } else { 1327 var rowsHtml = ''; 1328 for (var i = 0; i < rows.length; i++) { 1329 var r = rows[i]; 1330 var deltaHtml; 1331 if (r.delta === null) { 1332 deltaHtml = ''; 1333 } else if (r.delta > 0) { 1334 deltaHtml = '<span class="lb-trend-delta good">▴ +' + r.delta + '%</span>'; 1335 } else if (r.delta < 0) { 1336 deltaHtml = '<span class="lb-trend-delta bad">▾ ' + r.delta + '%</span>'; 1337 } else { 1338 deltaHtml = '<span class="lb-trend-delta">0%</span>'; 1339 } 1340 rowsHtml += 1341 '<div class="lb-trend-row">' + 1342 '<span class="lb-trend-rank">' + (i + 1) + '</span>' + 1343 heroPortraitHtml(r.row.characterSlug, 'lb-trend-port') + 1344 '<span class="lb-trend-name">' + esc(r.row.characterName) + '</span>' + 1345 '<span class="lb-trend-wr">' + r.winRate + '%</span>' + deltaHtml + 1346 '</div>'; 1347 } 1348 body = '<div class="lb-trend-list">' + rowsHtml + '</div>'; 1349 } 1350 } 1351 1352 return ( 1353 '<div class="tdtt-asidecard" style="--ac: var(--p-colosseum)">' + 1354 '<div class="tdtt-asidecard__head"><span class="tdtt-asidecard__icon">' + 1355 asideIcon(STAT_ICON.games) + '</span>Top Hero Trends</div>' + 1356 '<div class="seg-slab lb-trend-slab" id="lb-hero-trend-window">' + tabs + '</div>' + 1357 body + 1358 '</div>' 1359 ); 1360 } 1361 1362 function heroesAsideHtml() { 1363 return ( 1364 '<aside class="tdtt-aside lb-heroes-aside">' + 1365 '<div class="lb-heroes-tiles">' + 1366 statTileHtml(STAT_ICON.games, String(S.heroes.length), 'Total Heroes', 'var(--cyan)') + 1367 statTileHtml(STAT_ICON.games, fmtNum(S.heroTotalGames), 'Total Games', 'var(--gold)') + 1368 '</div>' + 1369 heroInsightsCardHtml() + 1370 heroTrendCardHtml() + 1371 '</aside>' 1372 ); 1373 } 1374 1375 function heroesLandingHtml() { 1376 if (S.heroesError) { 1377 return ( 1378 '<div class="lb-status lb-error"><p class="lb-error-title">Couldnât load hero stats</p>' + 1379 '<p class="lb-error-detail">' + esc(S.heroesError) + '</p>' + 1380 '<button type="button" class="lb-btn" id="lb-hero-retry">Retry</button></div>' 1381 ); 1382 } 1383 if (S.heroesLoading && !S.heroes.length) return loadingHtml(); 1384 if (!S.heroesLoading && !S.heroes.length) { 1385 return ( 1386 '<div class="lb-status lb-empty"><p class="lb-empty-title">No hero data yet</p>' + 1387 '<p class="lb-empty-sub">Hero stats appear once ranked games have been completed.</p></div>' 1388 ); 1389 } 1390 var filtered = S.heroes.filter(function (h) { 1391 return window.TDTT.lb.matchesHeroQuery(h.characterName, S.heroQuery); 1392 }); 1393 var sorted = window.TDTT.lb.sortHeroes(filtered, S.heroSort); 1394 var maxPicks = 0; 1395 for (var i = 0; i < S.heroes.length; i++) { 1396 if (S.heroes[i].picks > maxPicks) maxPicks = S.heroes[i].picks; 1397 } 1398 var cards = ''; 1399 for (var j = 0; j < sorted.length; j++) { 1400 cards += heroCardHtml(sorted[j], j + 1, maxPicks); 1401 } 1402 return ( 1403 '<div class="lb-heroes-layout">' + 1404 '<div class="lb-heroes-main">' + 1405 '<p class="lb-readout lb-heroes-count">Showing ' + sorted.length + ' of ' + S.heroes.length + 1406 ' heroes</p>' + 1407 '<div class="tdtt-cardgrid lb-hero-grid" id="lb-hero-grid">' + cards + '</div>' + 1408 '</div>' + 1409 heroesAsideHtml() + 1410 '</div>' 1411 ); 1412 } 1413 1414 function loadHeroes() { 1415 var game = S.game; 1416 setState({ heroesLoading: true, heroesError: null }); 1417 return window.TDTT.lb 1418 .heroes(game) 1419 .then(function (payload) { 1420 if (game !== S.game) return; // the game changed while this was in flight 1421 setState({ 1422 heroes: (payload && payload.characters) || [], 1423 heroTotalGames: (payload && payload.totalGames) || 0, 1424 heroesGame: game, 1425 heroesLoading: false, 1426 }); 1427 }) 1428 .catch(function (e) { 1429 if (game !== S.game) return; 1430 setState({ 1431 heroesLoading: false, 1432 heroesError: 1433 e && e.status === 401 ? 'Sign in to view hero stats.' : (e && e.message) || 'Failed to load', 1434 }); 1435 }); 1436 } 1437 1438 // Top Hero Trends' own fetch â INDEPENDENT of loadHeroes()/S.heroes above, 1439 // windowed by S.heroTrend.window. 'all' reuses lb.heroes(game)'s bare cache 1440 // key (leaderboard-data.js), so it never doubles the fetch loadHeroes() 1441 // already made. 1442 function loadHeroTrend() { 1443 var game = S.game; 1444 var w = S.heroTrend.window; 1445 var seq = ++heroTrendSeq; 1446 S.heroTrend.loading = true; 1447 S.heroTrend.data = null; 1448 S.heroTrendGame = game; 1449 render(); 1450 window.TDTT.lb 1451 .heroes(game, w === 'all' ? undefined : w) 1452 .then(function (payload) { 1453 if (seq !== heroTrendSeq) return; 1454 S.heroTrend.data = payload; 1455 S.heroTrend.loading = false;
1456 render(); 1457 }) 1458 .catch(function () { 1459 if (seq !== heroTrendSeq) return; 1460 S.heroTrend.data = null; 1461 S.heroTrend.loading = false; 1462 render(); 1463 }); 1464 } 1465 1466 // Re-entering the Heroes tab for the SAME game the trend data was already 1467 // fetched for is a no-op â loadHeroTrend() itself is always called 1468 // unconditionally after a real game switch (setFilter resets heroTrendGame 1469 // to null first). 1470 function loadHeroTrendIfStale() { 1471 if (S.heroTrendGame !== S.game) loadHeroTrend(); 1472 } 1473 1474 function setHeroTrendWindow(w) { 1475 if (S.heroTrend.window === w) return; 1476 S.heroTrend.window = w; 1477 loadHeroTrend(); 1478 } 1479 1480 // ââ Hero detail page (Task 10) ââââââââââââââââââââââââââââââââââââââââââââââ 1481 // leaderboard.js owns every fetch the hero page needs and builds the `ctx` 1482 // window.TDTT.lbHeroPage (lb-hero-page.js) renders/wires from â that module 1483 // does no fetching of its own. The exact contract, verbatim from the task 1484 // brief: 1485 // 1486 // { game, slug, window, heroes /* HeroStatRow[] all-time list */, heroesState, 1487 // windowRow /* row for current window or null */, windowState, 1488 // matchups, matchupsState, games, gamesState, 1489 // rosterEntry /* {name,set,difficulty} or null */, rosterState, 1490 // portraitHtml(slug, cls), bestOrWorst: 'best'|'worst', 1491 // onWindow(w), onBestWorst(k), onLeaderboards(), onBack(), 1492 // onOpenPlayer(id), onOpenCodex(slug) } 1493 // 1494 // `heroesState`/`windowState`/`matchupsState`/`gamesState`/`rosterState` 1495 // are all 'loading' | 'ready' | 'error'. `heroes` starts as [] and 1496 // `windowRow`/`rosterEntry` as null until their respective fetches 1497 // resolve â these *State fields are what let Task 10 tell "still fetching" 1498 // apart from "fetched, and this roster hero (or this window) genuinely has 1499 // zero games" rather than reading the empty array/null itself as the 1500 // answer (never fabricated). 1501 // 1502 // `rosterState` specifically (fix round 1, added after roster() and 1503 // heroes() were found to be independent, unordered fetches): `!rosterEntry` 1504 // alone is NOT "hero not found" â it is equally true while roster() is 1505 // still in flight, or if it rejects outright. Task 10 waits for 1506 // `rosterState` to settle before rendering "Hero not found", and when 1507 // roster() fails but the hero has all-time stats (a real characterId, 1508 // found in `heroes`), it renders the page anyway using that row's own 1509 // `characterName`, with set/difficulty shown as `â` rather than invented. 1510 // `onLeaderboards()` (-> the Players view) and `onBack()` (-> the Heroes 1511 // landing grid) are the breadcrumb's two distinct destinations, matching 1512 // desktop's "Leaderboards" / "Dice Throne" crumbs. 1513 1514 function findHeroRow(list, slug) { 1515 if (!list) return null; 1516 for (var i = 0; i < list.length; i++) { 1517 if (list[i].characterSlug === slug) return list[i]; 1518 } 1519 return null; 1520 } 1521 1522 function findRosterEntry(payload, slug) { 1523 var chars = (payload && payload.characters) || []; 1524 var sets = (payload && payload.sets) || []; 1525 var setNames = {}; 1526 for (var i = 0; i < sets.length; i++) setNames[sets[i].id] = sets[i].name; 1527 for (var j = 0; j < chars.length; j++) { 1528 // Roster hero objects key by `.id` (the slug) â there is no `.slug` 1529 // field, per codex.js's own convention (see this file's leaderboard- 1530 // data.js roster() comment). 1531 if (chars[j].id === slug) { 1532 return { 1533 name: chars[j].name, 1534 set: setNames[chars[j].set] || chars[j].set || null, 1535 difficulty: chars[j].difficulty || null, 1536 }; 1537 } 1538 } 1539 return null; 1540 } 1541 1542 function fetchHeroMatchups(game, characterId, w, seq, wseq) { 1543 S.heroPage.matchupsState = 'loading'; 1544 window.TDTT.lb 1545 .matchups(game, characterId, w) 1546 .then(function (list) { 1547 if (seq !== heroPageSeq || wseq !== heroPageWindowSeq) return; 1548 S.heroPage.matchups = Array.isArray(list) ? list : []; 1549 S.heroPage.matchupsState = 'ready'; 1550 render(); 1551 }) 1552 .catch(function () { 1553 if (seq !== heroPageSeq || wseq !== heroPageWindowSeq) return; 1554 S.heroPage.matchupsState = 'error'; 1555 render(); 1556 }); 1557 } 1558 1559 function fetchHeroGames(game, characterId, seq) { 1560 window.TDTT.lb 1561 .heroGames(game, characterId, window.TDTT.lbCore.HERO_PAGE_TREND_LENGTH) 1562 .then(function (payload) { 1563 if (seq !== heroPageSeq) return; 1564 var list = (payload && payload.games) || (Array.isArray(payload) ? payload : []); 1565 S.heroPage.games = list; 1566 S.heroPage.gamesState = 'ready'; 1567 render(); 1568 }) 1569 .catch(function () { 1570 if (seq !== heroPageSeq) return; 1571 S.heroPage.gamesState = 'error'; 1572 render(); 1573 }); 1574 } 1575
1576 // The all-time heroes list + roster entry are fetched ONCE per hero (not 1577 // per window) â the all-time list drives the rank chip and resolves the 1578 // characterId that matchups/games need; a hero with no games at all short- 1579 // circuits both of those to a ready, empty state rather than loading 1580 // forever (there is nothing to fetch). 1581 function fetchHeroPage(slug) { 1582 var game = S.game; 1583 var seq = heroPageSeq; 1584 var wseq = heroPageWindowSeq; 1585 1586 window.TDTT.lb 1587 .roster() 1588 .then(function (payload) { 1589 if (seq !== heroPageSeq) return; 1590 S.heroPage.rosterEntry = findRosterEntry(payload, slug); 1591 S.heroPage.rosterState = 'ready'; 1592 render(); 1593 }) 1594 .catch(function () { 1595 if (seq !== heroPageSeq) return; 1596 // rosterEntry stays null, but rosterState becomes 'error' â Task 10 1597 // falls back to the all-time row's own name/no-games state instead 1598 // of a fabricated "Hero not found" (fix round 1: roster() and 1599 // heroes() are independent, unordered fetches, so a slow/failed 1600 // roster() must never be read as "this hero doesn't exist"). 1601 S.heroPage.rosterState = 'error'; 1602 render(); 1603 }); 1604 1605 window.TDTT.lb 1606 .heroes(game) 1607 .then(function (payload) { 1608 if (seq !== heroPageSeq) return; 1609 var list = (payload && payload.characters) || []; 1610 S.heroPage.heroes = list; 1611 S.heroPage.heroesState = 'ready'; 1612 var row = findHeroRow(list, slug); 1613 if (S.heroPage.window === 'all') { 1614 S.heroPage.windowRow = row; 1615 S.heroPage.windowState = 'ready'; 1616 } 1617 render(); 1618 if (!row) { 1619 S.heroPage.matchupsState = 'ready'; 1620 S.heroPage.matchups = []; 1621 S.heroPage.gamesState = 'ready'; 1622 S.heroPage.games = []; 1623 render(); 1624 return; 1625 } 1626 fetchHeroMatchups(game, row.characterId, S.heroPage.window, seq, wseq); 1627 fetchHeroGames(game, row.characterId, seq); 1628 }) 1629 .catch(function () { 1630 if (seq !== heroPageSeq) return; 1631 S.heroPage.heroesState = 'error'; 1632 if (S.heroPage.window === 'all') S.heroPage.windowState = 'error'; 1633 render(); 1634 }); 1635 } 1636 1637 function onHeroWindow(w) { 1638 if (!S.heroPage || S.heroPage.window === w) return; 1639 S.heroPage.window = w; 1640 heroPageWindowSeq++; 1641 var wseq = heroPageWindowSeq; 1642 var seq = heroPageSeq; 1643 var game = S.game; 1644 var row = findHeroRow(S.heroPage.heroes, S.heroSlug); 1645 if (w === 'all') { 1646 S.heroPage.windowRow = row; 1647 // Mirrors the already-known state of the SAME all-time fetch that 1648 // heroesState tracks â window 'all' never makes its own request. 1649 S.heroPage.windowState = S.heroPage.heroesState; 1650 } else { 1651 S.heroPage.windowRow = null; // unknown until the windowed fetch resolves 1652 S.heroPage.windowState = 'loading'; 1653 window.TDTT.lb 1654 .heroes(game, w) 1655 .then(function (payload) { 1656 if (seq !== heroPageSeq || wseq !== heroPageWindowSeq) return; 1657 var list = (payload && payload.characters) || []; 1658 S.heroPage.windowRow = findHeroRow(list, S.heroSlug); 1659 S.heroPage.windowState = 'ready'; 1660 render(); 1661 }) 1662 .catch(function () { 1663 if (seq !== heroPageSeq || wseq !== heroPageWindowSeq) return; 1664 S.heroPage.windowRow = null; 1665 S.heroPage.windowState = 'error'; 1666 render(); 1667 }); 1668 } 1669 render(); 1670 if (row) fetchHeroMatchups(game, row.characterId, w, seq, wseq); 1671 } 1672 1673 function onHeroBestWorst(k) { 1674 if (!S.heroPage || S.heroPage.bestOrWorst === k) return; 1675 S.heroPage.bestOrWorst = k; 1676 render(); 1677 } 1678 1679 function onOpenCodexForHero(slug) { 1680 if (window.TDTTRouter) { 1681 window.TDTTRouter.go('codex', '?game=DICE_THRONE&hero=' + encodeURIComponent(slug)); 1682 } 1683 } 1684 1685 function buildHeroPageCtx() { 1686 var hp = S.heroPage || {}; 1687 return { 1688 game: S.game, 1689 slug: S.heroSlug, 1690 window: hp.window || 'all', 1691 heroes: hp.heroes || [], 1692 // 'loading' | 'ready' | 'error' â lets Task 10 tell "still fetching the 1693 // all-time list" apart from "fetched, and this roster hero genuinely 1694 // has zero games" (both start with heroes:[] / windowRow:null). 1695 heroesState: hp.heroesState || 'loading', 1696 windowRow: hp.windowRow || null, 1697 // Same three-state shape, scoped to the CURRENT window only â mirrors 1698 // heroesState while window is 'all' (same fetch), tracks its own 1699 // request otherwise. See onHeroWindow()/fetchHeroPage() above. 1700 windowState: hp.windowState || 'loading', 1701 matchups: hp.matchups, 1702 matchupsState: hp.matchupsState, 1703 games: hp.games, 1704 gamesState: hp.gamesState, 1705 rosterEntry: hp.rosterEntry || null, 1706 // 'loading' | 'ready' | 'error' (fix round 1) â roster() and heroes() 1707 // are independent, unordered fetches, so `!rosterEntry` alone cannot 1708 // mean "not found": it's also true while roster() is still in flight, 1709 // or if it fails outright. Task 10 waits for this to settle before 1710 // rendering "Hero not found", and falls back to the all-time row's 1711 // own name (set/difficulty shown as `â`) rather than fabricating 1712 // either state when only one of the two fetches came back. 1713 rosterState: hp.rosterState || 'loading', 1714 bestOrWorst: hp.bestOrWorst || 'best', 1715 // Task 10 renders portraits through this rather than a second .jpg-> 1716 // .png fallback copy â see heroPortraitHtml()'s own comment. 1717 portraitHtml: heroPortraitHtml, 1718 onWindow: onHeroWindow, 1719 onBestWorst: onHeroBestWorst, 1720 // Two distinct breadcrumb destinations, matching desktop: 1721 // "Leaderboards" -> the Players view (setView, hoisted below); 1722 // "Dice Throne" -> back to the Heroes landing grid (closeHeroPage). 1723 onLeaderboards: function () {
1724 setView('players'); 1725 }, 1726 onBack: closeHeroPage, 1727 onOpenPlayer: viewFullProfile, 1728 onOpenCodex: onOpenCodexForHero, 1729 }; 1730 } 1731 1732 // Opens the hero page for `slug`, always inside the Heroes tab â hero cards, 1733 // Top Hero Trends and Drafts/Bans rows all funnel through this one function 1734 // so `?view=heroes&hero=<slug>` is the only shape that ever reaches the URL. 1735 function openHeroPage(slug) { 1736 if (!slug) return; 1737 S.view = 'heroes'; 1738 S.heroSlug = slug; 1739 S.heroPage = { 1740 window: 'all', 1741 bestOrWorst: 'best', 1742 heroes: [], 1743 heroesState: 'loading', 1744 windowRow: null, 1745 windowState: 'loading', 1746 matchups: null, 1747 matchupsState: 'loading', 1748 games: null, 1749 gamesState: 'loading', 1750 rosterEntry: null, 1751 rosterState: 'loading', 1752 }; 1753 heroPageSeq++; 1754 heroPageWindowSeq++; 1755 syncUrl(); 1756 render(); 1757 fetchHeroPage(slug); 1758 } 1759 1760 function closeHeroPage() { 1761 S.heroSlug = null; 1762 S.heroPage = null; 1763 heroPageSeq++; 1764 heroPageWindowSeq++; 1765 syncUrl(); 1766 render(); 1767 if (S.heroesGame !== S.game && !S.heroesLoading) loadHeroes(); 1768 } 1769 1770 // ââ Drafts / Bans (Task 11) ââââââââââââââââââââââââââââââââââââââââââââââââââ 1771 // Same delegation shape as the hero page: leaderboard.js fetches, Task 11's 1772 // window.TDTT.lbDraftsBans renders/wires. ONE ctx shape serves both 1773 // draftsHtml(ctx) and bansHtml(ctx) since only one is ever on screen: 1774 // 1775 // { game, draft /* draftStats() {totalDrafts,characters} | null */, draftState, 1776 // heroes /* HeroStatRow[] | null, Drafts only */, 1777 // bans /* bans(game) {heroes,cards} | null, Bans only */, bansState, 1778 // rosterSize /* number | null â null until roster() resolves, renders 'â' */, 1779 // sort /* active DraftSortKey, Drafts only */, query /* Drafts only */, 1780 // onSort(k), onQuery(q), onOpenHero(slug) } 1781 // 1782 // draftState/bansState are 'loading' | 'ready' | 'error'. 1783 1784 function loadRosterSize(seq) { 1785 if (S.draftsBans.rosterSize !== null) return; // already known â roster() is itself cached 1786 window.TDTT.lb 1787 .roster() 1788 .then(function (payload) { 1789 if (seq !== draftsBansSeq) return; 1790 var count = 1791 (payload && typeof payload.count === 'number' && payload.count) || 1792 (payload && payload.characters && payload.characters.length) || 1793 null; 1794 S.draftsBans.rosterSize = count; 1795 render(); 1796 }) 1797 .catch(function () { 1798 /* rosterSize stays null â the roster-% readouts render 'â' */ 1799 }); 1800 } 1801 1802 function loadDraftsData() { 1803 var game = S.game; 1804 var seq = ++draftsBansSeq; 1805 S.draftsBans.draftState = 'loading'; 1806 S.draftsBans.heroesState = 'loading'; 1807 render(); 1808 window.TDTT.lb 1809 .draftStats() 1810 .then(function (payload) { 1811 if (seq !== draftsBansSeq) return; 1812 S.draftsBans.draft = payload; 1813 S.draftsBans.draftState = 'ready'; 1814 render(); 1815 }) 1816 .catch(function () { 1817 if (seq !== draftsBansSeq) return; 1818 S.draftsBans.draftState = 'error'; 1819 render(); 1820 }); 1821 window.TDTT.lb 1822 .heroes(game) 1823 .then(function (payload) { 1824 if (seq !== draftsBansSeq) return; 1825 S.draftsBans.heroes = (payload && payload.characters) || []; 1826 S.draftsBans.heroesState = 'ready'; 1827 render(); 1828 }) 1829 .catch(function () { 1830 if (seq !== draftsBansSeq) return; 1831 S.draftsBans.heroesState = 'error'; 1832 render(); 1833 }); 1834 loadRosterSize(seq); 1835 } 1836 1837 function loadBansData() { 1838 var game = S.game; 1839 var seq = ++draftsBansSeq; 1840 S.draftsBans.bansState = 'loading'; 1841 S.draftsBans.draftState = 'loading'; 1842 render(); 1843 window.TDTT.lb 1844 .bans(game) 1845 .then(function (payload) { 1846 if (seq !== draftsBansSeq) return; 1847 S.draftsBans.bans = payload; 1848 S.draftsBans.bansState = 'ready'; 1849 render(); 1850 }) 1851 .catch(function () { 1852 if (seq !== draftsBansSeq) return; 1853 S.draftsBans.bansState = 'error'; 1854 render(); 1855 }); 1856 window.TDTT.lb 1857 .draftStats() 1858 .then(function (payload) { 1859 if (seq !== draftsBansSeq) return; 1860 S.draftsBans.draft = payload; 1861 S.draftsBans.draftState = 'ready'; 1862 render(); 1863 }) 1864 .catch(function () { 1865 if (seq !== draftsBansSeq) return; 1866 S.draftsBans.draftState = 'error'; 1867 render(); 1868 }); 1869 loadRosterSize(seq); 1870 } 1871 1872 function setDraftSort(k) { 1873 if (S.draftsBans.sort === k) return; 1874 S.draftsBans.sort = k; 1875 render(); 1876 } 1877 1878 function setDraftQuery(q) { 1879 S.draftsBans.query = q || ''; 1880 render(); 1881 } 1882 1883 function buildDraftsBansCtx() {
1884 var db = S.draftsBans; 1885 return { 1886 game: S.game, 1887 draft: db.draft, 1888 draftState: db.draftState, 1889 heroes: db.heroes, 1890 heroesState: db.heroesState, 1891 bans: db.bans, 1892 bansState: db.bansState, 1893 rosterSize: db.rosterSize, 1894 sort: db.sort, 1895 query: db.query, 1896 // Task 11 renders portraits through this rather than a second .jpg-> 1897 // .png fallback copy â see heroPortraitHtml()'s own comment (also used 1898 // by buildHeroPageCtx() above). 1899 portraitHtml: heroPortraitHtml, 1900 onSort: setDraftSort, 1901 onQuery: setDraftQuery, 1902 onOpenHero: openHeroPage, 1903 }; 1904 } 1905 1906 // ââ `?view=`/`?hero=` deep-link routing (Task 9, extended Task 8) âââââââââââ 1907 // Read with the exact try/catch shape profile.js's viewedPlayerId() uses 1908 // (lines 151-159) â a malformed location.search degrades to the defaults 1909 // instead of throwing. `view` is validated against the five real views 1910 // ('formats' added Task 8) and `hero` is only honoured alongside 1911 // `view=heroes`. 1912 function readUrlState() { 1913 var view = null; 1914 var hero = null; 1915 var deck = null; 1916 var game = null; 1917 try { 1918 var params = new URLSearchParams(window.location.search); 1919 var v = params.get('view'); 1920 if (v === 'players' || v === 'heroes' || v === 'drafts' || v === 'bans' || v === 'formats' || v === 'decks') { 1921 view = v; 1922 } 1923 hero = params.get('hero') || null; 1924 deck = params.get('deck') || null; 1925 // `?game=<GameType>` (a House chat leaderboard card links here): only a real GameType from 1926 // GAMES is honoured, anything else is ignored. 1927 var g = params.get('game'); 1928 for (var gi = 0; gi < GAMES.length; gi++) { 1929 if (GAMES[gi].id === g) game = g; 1930 } 1931 } catch (e) { 1932 /* malformed location.search â fall through to the defaults */ 1933 } 1934 return { view: view, hero: hero, deck: deck, game: game }; 1935 } 1936 1937 function buildSearch() { 1938 var search = '?view=' + encodeURIComponent(S.view); 1939 if (S.view === 'heroes' && S.heroSlug) search += '&hero=' + encodeURIComponent(S.heroSlug); 1940 if (S.view === 'decks' && S.deckKey) search += '&deck=' + encodeURIComponent(S.deckKey); 1941 return search; 1942 } 1943 1944 // Same convention as codex.js's setGame(): `replaceState`, not `pushState` â 1945 // this is one section stepping through its own sub-views, not a page-to-page 1946 // navigation router.js's popstate handling needs a history entry for. 1947 function syncUrl() { 1948 try { 1949 window.history.replaceState({ section: 'leaderboard' }, '', '/app/leaderboard' + buildSearch()); 1950 } catch (e) { 1951 /* history API unavailable â the in-memory S still drives render() */ 1952 } 1953 } 1954 1955 // Non-Dice-Throne games have no hero data at all, and Dice Throne has no 1956 // Formats/Decks tab (the mirror image) â same fallback rule setFilter( 1957 // 'game', ...) already applies elsewhere in this file. 1958 function resolveUrlView(v) { 1959 if (v !== 'players' && v !== 'heroes' && v !== 'drafts' && v !== 'bans' && v !== 'formats' && v !== 'decks') { 1960 return 'players'; 1961 } 1962 if ((v === 'heroes' || v === 'drafts' || v === 'bans') && !isHeroGame()) return 'players'; 1963 if ((v === 'formats' || v === 'decks') && isHeroGame()) return 'players'; 1964 return v; 1965 } 1966 1967 function applyInitialUrlState() { 1968 var u = readUrlState(); 1969 // Before the view is resolved: whether Heroes/Drafts/Bans or Formats/Decks exist depends on 1970 // the game. A game that arrives in the URL is a link, not a saved choice, so it is not stored. 1971 if (u.game && u.game !== S.game) { 1972 S.game = u.game; 1973 S.format = ''; 1974 } 1975 S.view = resolveUrlView(u.view); 1976 if (S.view === 'heroes' && u.hero) { 1977 S.heroSlug = u.hero; 1978 S.heroPage = { 1979 window: 'all', 1980 bestOrWorst: 'best', 1981 heroes: [], 1982 heroesState: 'loading', 1983 windowRow: null, 1984 windowState: 'loading', 1985 matchups: null, 1986 matchupsState: 'loading', 1987 games: null, 1988 gamesState: 'loading', 1989 rosterEntry: null, 1990 rosterState: 'loading', 1991 }; 1992 } 1993 if (S.view === 'decks' && u.deck) { 1994 S.deckKey = u.deck; 1995 } 1996 } 1997 1998 // Handles the browser Back/Forward buttons â re-reads the URL and applies 1999 // whatever it now says, WITHOUT calling syncUrl() again (that would be 2000 // reacting to our own write). Registered in mount()/removed in unmount(), 2001 // same as profile.js's onTabPopState â this listener lives on `window`, 2002 // outside the router-owned #app-content, so nothing else will remove it. 2003 function onPopState() { 2004 var u = readUrlState();
2005 var v = resolveUrlView(u.view); 2006 2007 if (v === 'heroes' && u.hero) { 2008 if (S.view !== 'heroes' || S.heroSlug !== u.hero) { 2009 S.view = 'heroes'; 2010 S.heroSlug = u.hero; 2011 S.heroPage = { 2012 window: 'all', 2013 bestOrWorst: 'best', 2014 heroes: [], 2015 heroesState: 'loading', 2016 windowRow: null, 2017 windowState: 'loading', 2018 matchups: null, 2019 matchupsState: 'loading', 2020 games: null, 2021 gamesState: 'loading', 2022 rosterEntry: null, 2023 rosterState: 'loading', 2024 }; 2025 heroPageSeq++; 2026 heroPageWindowSeq++; 2027 render(); 2028 fetchHeroPage(u.hero); 2029 } 2030 return; 2031 } 2032 2033 if (v === 'decks' && u.deck) { 2034 if (S.view !== 'decks' || S.deckKey !== u.deck) { 2035 S.view = 'decks'; 2036 S.deckKey = u.deck; 2037 S.deckPage.detailState = 'loading'; 2038 S.deckPage.detail = null; 2039 S.deckPage.matchupsState = 'loading'; 2040 S.deckPage.matchups = null; 2041 S.deckPage.gamesState = 'loading'; 2042 S.deckPage.games = null; 2043 render(); 2044 loadDeckPage(u.deck); 2045 } 2046 return; 2047 } 2048 2049 var hadHero = !!S.heroSlug; 2050 var hadDeck = !!S.deckKey; 2051 S.heroSlug = null; 2052 S.heroPage = null; 2053 heroPageSeq++; 2054 heroPageWindowSeq++; 2055 S.deckKey = null; 2056 S.deckPage.token++; 2057 2058 if (S.view === v) { 2059 if (hadHero || hadDeck) render(); // closed a hero/deck page, same tab (e.g. Back from a deep link) 2060 return; 2061 } 2062 S.view = v; 2063 if (v === 'heroes') { 2064 render(); 2065 if (S.heroesGame !== S.game && !S.heroesLoading) loadHeroes(); 2066 loadHeroTrendIfStale(); 2067 return; 2068 } 2069 if (v === 'drafts') { 2070 render(); 2071 loadDraftsData(); 2072 return; 2073 } 2074 if (v === 'bans') { 2075 render(); 2076 loadBansData(); 2077 return; 2078 } 2079 if (v === 'formats') { 2080 render(); 2081 loadFormatsTabIfStale(); 2082 return; 2083 } 2084 if (v === 'decks') { 2085 render(); 2086 loadTcgRailIfStale(); 2087 loadDecksGridIfStale(); 2088 return; 2089 } 2090 if (v === 'players' && S.scope === 'ranked' && showsTcgFeatures()) loadTcgRailIfStale(); 2091 if (S.playersDirty) { 2092 S.playersDirty = false; 2093 load(true); 2094 return; 2095 } 2096 render(); 2097 } 2098 2099 function setView(view) { 2100 if (S.view === view && !S.heroSlug && !S.deckKey) return; 2101 S.view = view; 2102 // A hero/deck page left open behind another tab would reopen on return 2103 // with stale matchups/games belonging to a hero/archetype the user has 2104 // since forgotten. 2105 S.heroSlug = null; 2106 S.heroPage = null; 2107 heroPageSeq++; 2108 heroPageWindowSeq++; 2109 S.deckKey = null; 2110 S.deckPage.token++; 2111 if (view === 'heroes') { 2112 syncUrl(); 2113 render(); 2114 if (S.heroesGame !== S.game && !S.heroesLoading) loadHeroes(); 2115 loadHeroTrendIfStale(); 2116 return; 2117 } 2118 if (view === 'drafts') { 2119 syncUrl(); 2120 render(); 2121 loadDraftsData(); 2122 return; 2123 } 2124 if (view === 'bans') { 2125 syncUrl(); 2126 render(); 2127 loadBansData(); 2128 return; 2129 } 2130 if (view === 'formats') { 2131 syncUrl(); 2132 render(); 2133 loadFormatsTabIfStale(); 2134 return; 2135 } 2136 if (view === 'decks') { 2137 syncUrl(); 2138 render(); 2139 loadTcgRailIfStale(); 2140 loadDecksGridIfStale(); 2141 return; 2142 } 2143 syncUrl(); 2144 if (view === 'players' && S.scope === 'ranked' && showsTcgFeatures()) loadTcgRailIfStale(); 2145 if (S.playersDirty) { 2146 S.playersDirty = false; 2147 load(true); 2148 return; 2149 } 2150 render(); 2151 } 2152 2153 function filterBarHtml() { 2154 // Game logo rail batch, Task 3: the game chip row is now the shared 2155 // rail (window.TDTT.gameRail), matching Codex and Rules. 2156 var gameRailHtml = window.TDTT.gameRail 2157 ? window.TDTT.gameRail.html(gamesForRail(), S.game, { attr: 'data-game' }) 2158 : ''; 2159 2160 var sortChips = SORTS.map(function (s) { 2161 return ( 2162 '<button type="button" class="chip' + (s.id === S.sort ? ' on' : '') + '" data-sort="' + 2163 esc(s.id) + '">' + esc(s.label) + '</button>' 2164 ); 2165 }).join(''); 2166 2167 var heroSortChips = HERO_SORTS.map(function (h) { 2168 return ( 2169 '<button type="button" class="chip' + (h.id === S.heroSort ? ' on' : '') + '" data-hsort="' + 2170 esc(h.id) + '">' + esc(h.label) + '</button>' 2171 ); 2172 }).join(''); 2173 2174 // Region, sort, search and "show my place" are all properties of a PLAYER 2175 // ranking â none of them apply to the hero grid, an open hero page, the 2176 // Drafts/Bans views (Task 11 renders its own sort/search inside its 2177 // body), or the Formats tab (Task 8 renders its own window chips inside 2178 // its own body/rail), so those views carry their own single control row 2179 // (or none at all) rather than showing dead controls. 2180 var controls; 2181 if (S.view === 'heroes' && !S.heroSlug) { 2182 controls = 2183 '<div class="lb-filterrow">' + 2184 '<div class="chip-row" id="lb-hsorts">' + heroSortChips + '</div>' + 2185 '<div class="lb-search-wrap">' + 2186 '<input type="search" id="lb-hero-search" class="lb-search-input" placeholder="Search heroesâ¦" ' + 2187 'autocomplete="off" value="' + esc(S.heroQuery) + '">' + 2188 '</div>' + 2189 '</div>'; 2190 } else if (S.view === 'heroes' || S.view === 'drafts' || S.view === 'bans' || S.view === 'formats') { 2191 controls = ''; 2192 } else { 2193 controls = 2194 myPlaceHtml() + 2195 '<div class="lb-filterrow">' + 2196 '<div class="chip-row" id="lb-sorts">' + sortChips + '</div>' + 2197 '<select class="lb-select" id="lb-region" aria-label="Filter by region">' + regionOptionsHtml() + '</select>' + 2198 '</div>'; 2199 } 2200 2201 return ( 2202 '<div class="lb-filterbar">' + 2203 gameRailHtml + 2204 tabsAndSearchRowHtml() + 2205 controls + 2206 '</div>' 2207 ); 2208 } 2209 2210 // Bare .seg-slab markup (no wrapping row). Dice Throne keeps 2211 // Players/Heroes/Drafts/Bans (keyed by character); every other game has no 2212 // per-character data or hero-ban tracking, so it gets Players/Decks/Formats 2213 // instead (Task 8 + Task 9) â mirrors desktop's Leaderboard.tsx modeTabs 2214 // exactly, same order. 2215 function viewTabsSegHtml() { 2216 var tabDefs = isHeroGame() 2217 ? [ 2218 { id: 'players', label: 'Players' }, 2219 { id: 'heroes', label: 'Heroes' }, 2220 { id: 'drafts', label: 'Drafts' }, 2221 { id: 'bans', label: 'Bans' }, 2222 ] 2223 : [ 2224 { id: 'players', label: 'Players' }, 2225 { id: 'decks', label: 'Decks' }, 2226 { id: 'formats', label: 'Formats' }, 2227 ]; 2228 var tabs = tabDefs 2229 .map(function (v) { 2230 return ( 2231 '<button type="button" class="t' + (v.id === S.view ? ' on' : '') + '" data-view="' + 2232 esc(v.id) + '">' + esc(v.label) + '</button>' 2233 ); 2234 }) 2235 .join(''); 2236 return '<div class="seg-slab" id="lb-views">' + tabs + '</div>'; 2237 } 2238 2239 // The player search box (Task 4 Step 4) â extracted so it can sit BESIDE 2240 // the mode-tab row per the brief, instead of stacked in its own row further 2241 // down with the sort chips.
2242 function searchWrapHtml() { 2243 return ( 2244 '<div class="lb-search-wrap">' + 2245 '<input type="search" id="lb-search" class="lb-search-input" placeholder="Search playersâ¦" autocomplete="off">' + 2246 '<div id="lb-search-results" class="lb-search-results" hidden></div>' + 2247 '</div>' 2248 ); 2249 } 2250 2251 // The format `<select>` (Task 8, non-Dice-Throne Players/ranked only) â 2252 // "All formats" plus every window.TDTT.decks.deckFormatOptions(S.game) 2253 // entry, mirroring desktop's FormatDropdown exactly. A plain `<select>` 2254 // rather than a chip row â the format list is long and per-game (MTG has 2255 // 6, Riftbound 0), unlike the small, fixed scope/mode rows those use. 2256 function formatSelectHtml() { 2257 var opts = window.TDTT.decks ? window.TDTT.decks.deckFormatOptions(S.game) : []; 2258 var html = '<option value="">All formats</option>'; 2259 for (var i = 0; i < opts.length; i++) { 2260 html += 2261 '<option value="' + esc(opts[i].id) + '"' + (opts[i].id === S.format ? ' selected' : '') + '>' + 2262 esc(opts[i].name) + '</option>'; 2263 } 2264 return '<select class="lb-select" id="lb-format" aria-label="Format">' + html + '</select>'; 2265 } 2266 2267 // Combines the mode tabs, the format select and the player search into one 2268 // row. Search is player-scoped (it filters the CURRENT scope's player list 2269 // via the server-side /leaderboard/search, per the brief), so it only 2270 // makes sense next to the Players tab â Heroes/Drafts/Bans/Formats hide it 2271 // rather than showing a control that does nothing on their view. The 2272 // format select is Players + ranked + TCG-game only, same gate desktop's 2273 // FormatDropdown uses. 2274 function tabsAndSearchRowHtml() { 2275 var tabs = viewTabsSegHtml(); 2276 var isPlayers = S.view === 'players'; 2277 var format = isPlayers && S.scope === 'ranked' && showsTcgFeatures() ? formatSelectHtml() : ''; 2278 var search = isPlayers ? searchWrapHtml() : ''; 2279 if (!tabs && !format && !search) return ''; 2280 return '<div class="lb-filterrow">' + tabs + format + search + '</div>'; 2281 } 2282 2283 // Drafts/Bans tab CONTENT is rendered by window.TDTT.lbDraftsBans (Task 11) â 2284 // see draftsBodyHtml()/bansBodyHtml() below, which guard for that module not 2285 // being loaded yet and fall back to loadingHtml() rather than throwing. 2286
2287 function renderSearchResults(el, list) { 2288 if (!list || !list.length) { 2289 el.hidden = false; 2290 el.innerHTML = '<div class="lb-search-empty">No players found.</div>'; 2291 return; 2292 } 2293 el.hidden = false; 2294 el.innerHTML = list 2295 .map(function (p) { 2296 return ( 2297 '<button type="button" class="lb-hit" data-pid="' + esc(p.id) + '">' + 2298 avatarHtml({ avatarUrl: p.avatarUrl, displayName: p.displayName }, '') + 2299 '<span class="lb-hit-b"><span class="lb-hit-nm">' + esc(p.displayName) + '</span>' + 2300 (p.region ? '<span class="lb-hit-sub">' + esc(p.region) + '</span>' : '') + 2301 '</span><span class="lb-hit-rt">' + numOrDash(p.skillRating) + '</span></button>' 2302 ); 2303 }) 2304 .join(''); 2305 wireAvatarFallback(el); 2306 var hits = el.querySelectorAll('.lb-hit'); 2307 for (var i = 0; i < hits.length; i++) { 2308 hits[i].addEventListener('click', function (e) { 2309 var pid = e.currentTarget.getAttribute('data-pid'); 2310 el.hidden = true; 2311 el.innerHTML = ''; 2312 openPlayerProfile(pid); 2313 }); 2314 } 2315 } 2316 2317 // Debounced at 300ms with a 2-character minimum and a stale-keystroke 2318 // guard (matching the mobile page) â kept OUTSIDE the state/render cycle so 2319 // typing never triggers a full filter-bar/list re-render and loses focus. 2320 function wireSearch(inputEl, resultsEl) { 2321 if (!inputEl || !resultsEl) return; 2322 inputEl.addEventListener('input', function () { 2323 var q = inputEl.value.trim(); 2324 if (searchTimer) clearTimeout(searchTimer); 2325 if (q.length < 2) { 2326 resultsEl.hidden = true; 2327 resultsEl.innerHTML = ''; 2328 return; 2329 } 2330 var seq = ++searchSeq; 2331 searchTimer = setTimeout(function () { 2332 window.TDTT.lb 2333 .search(q, S.game) 2334 .then(function (list) { 2335 if (seq !== searchSeq) return; // a newer keystroke has already fired 2336 renderSearchResults(resultsEl, list); 2337 }) 2338 .catch(function (e) { 2339 if (seq !== searchSeq) return; 2340 resultsEl.hidden = false; 2341 resultsEl.innerHTML = 2342 '<div class="lb-search-empty">' + 2343 (e && e.status === 401 ? 'Sign in to search players.' : 'Search failed.') + 2344 '</div>'; 2345 }); 2346 }, 300); 2347 }); 2348 } 2349 2350 function setFilter(key, value) { 2351 if (S[key] === value) return; 2352 S[key] = value; 2353 S.myPlaceStatus = null; // stale "unranked"/error copy would misdescribe the new filters 2354 // The rank card is per game+region (see loadMyRank()'s comment) â a sort 2355 // change doesn't affect it, but a game or region change does. 2356 if (key === 'game' || key === 'region') loadMyRank(); 2357 if (key === 'game') { 2358 try { 2359 localStorage.setItem('tdtt_lb_game', value); 2360 } catch (e) { 2361 /* private mode / storage disabled â filter still works this session */ 2362 } 2363 // Heroes/Drafts/Bans exist for Dice Throne only â fall back to Players 2364 // when the new game has no per-character data / hero-keyed draft-ban 2365 // tracking, the rule both existing clients follow. 2366 if ((S.view === 'heroes' || S.view === 'drafts' || S.view === 'bans') && !isHeroGame()) { 2367 S.view = 'players'; 2368 syncUrl(); 2369 } 2370 // Formats/Decks are the mirror image (Task 8 + Task 9) â they only 2371 // exist for non-Dice-Throne games. Bounce back to Players so we never 2372 // strand the viewer on a tab that's about to disappear. 2373 if ((S.view === 'formats' || S.view === 'decks') && isHeroGame()) { 2374 S.view = 'players'; 2375 syncUrl(); 2376 } 2377 // The format dropdown is per-game (each game has its own format ids â 2378 // MTG's 'standard' is not a format on Pokémon), so a stale selection 2379 // must never carry over to the new game â mirrors desktop's 2380 // handleSelectGame resetting `format` unconditionally. 2381 S.format = ''; 2382 S.heroes = []; 2383 S.heroTotalGames = 0; 2384 S.heroesGame = null; 2385 S.heroesError = null; 2386 S.heroQuery = ''; 2387 S.heroTrend = { window: S.heroTrend.window, loading: true, data: null }; 2388 // A hero page open for the OLD game's roster can't survive a game 2389 // switch â close it the same way setView() does. 2390 S.heroSlug = null; 2391 S.heroPage = null; 2392 heroPageSeq++; 2393 heroPageWindowSeq++; 2394 S.draftsBans = { 2395 draft: null, draftState: 'loading', 2396 heroes: null, heroesState: 'loading', 2397 bans: null, bansState: 'loading', 2398 rosterSize: null, 2399 sort: S.draftsBans.sort, 2400 query: S.draftsBans.query, 2401 }; 2402 // Deck archetype buckets and format ids are both per-game (Task 9) â 2403 // a favourite/open deck from the OLD game can't survive a switch, same 2404 // reasoning as the hero page close above. `game: null` (rather than the 2405 // new game id) forces loadDecksGridIfStale() to treat this as never 2406 // having been fetched for the new game, so it always refetches. 2407 S.deckKey = null; 2408 S.deckPage.token++; 2409 S.decksTab.format = ''; 2410 S.decksTab.decks = []; 2411 S.decksTab.totals = null; 2412 S.decksTab.game = null; 2413 S.decksTab.gridState = 'loading'; 2414 if (S.view === 'heroes') { 2415 // The player list is off screen; mark it stale rather than spending a 2416 // request on rows nobody is looking at (setView reloads it on return). 2417 S.playersDirty = true; 2418 S.rows = []; 2419 S.offset = 0; 2420 render(); 2421 loadHeroes(); 2422 loadHeroTrend(); 2423 return; 2424 } 2425 if (S.view === 'drafts') { 2426 S.playersDirty = true; 2427 S.rows = []; 2428 S.offset = 0; 2429 render(); 2430 loadDraftsData(); 2431 return; 2432 } 2433 if (S.view === 'bans') { 2434 S.playersDirty = true; 2435 S.rows = []; 2436 S.offset = 0; 2437 render(); 2438 loadBansData(); 2439 return; 2440 } 2441 if (S.view === 'formats') { 2442 S.playersDirty = true; 2443 S.rows = []; 2444 S.offset = 0; 2445 render(); 2446 loadFormatsTab(S.formatsTab.window); 2447 return; 2448 } 2449 if (S.view === 'decks') { 2450 S.playersDirty = true; 2451 S.rows = []; 2452 S.offset = 0; 2453 render(); 2454 loadTcgRailIfStale(); 2455 loadDecksGrid(); 2456 return; 2457 } 2458 } 2459 if (S.view === 'players' && S.scope === 'ranked' && showsTcgFeatures()) loadTcgRailIfStale(); 2460 load(true); 2461 } 2462 2463 // Delegated click handler for hero cards â the Heroes landing grid, and any 2464 // future grid Task 11 renders inside `scope` (drafts/bans hero rows use 2465 // their own wiring in lb-drafts-bans.js, not this). Scoped to `[data-hero- 2466 // slug]` so it never fires for an unrelated tile that happens to share a 2467 // parent. 2468 function wireHeroGridTriggers(scope) { 2469 var tiles = scope.querySelectorAll('[data-hero-slug]'); 2470 for (var i = 0; i < tiles.length; i++) { 2471 tiles[i].addEventListener('click', function (e) { 2472 openHeroPage(e.currentTarget.getAttribute('data-hero-slug')); 2473 }); 2474 } 2475 } 2476 2477 // Calls into Task 10 (window.TDTT.lbHeroPage) / Task 11 2478 // (window.TDTT.lbDraftsBans) to wire the body THIS render() just painted â 2479 // guarded because neither module exists until its own task ships (see this 2480 // file's top-of-file comment). Must run AFTER root.innerHTML is set, same 2481 // as every other wiring call in this function. 2482 function wireDelegatedModules() { 2483 if (S.view === 'heroes' && S.heroSlug) { 2484 var heroMod = window.TDTT && window.TDTT.lbHeroPage; 2485 if (heroMod && typeof heroMod.wire === 'function') heroMod.wire(root, buildHeroPageCtx()); 2486 return; 2487 } 2488 if (S.view === 'drafts' || S.view === 'bans') { 2489 var dbMod = window.TDTT && window.TDTT.lbDraftsBans; 2490 if (dbMod && typeof dbMod.wire === 'function') dbMod.wire(root, buildDraftsBansCtx()); 2491 return; 2492 } 2493 if (S.view === 'decks') { 2494 var decksMod = window.TDTT && window.TDTT.lbDecksView; 2495 if (decksMod && typeof decksMod.wire === 'function') { 2496 decksMod.wire(root, S.deckKey ? buildDeckPageCtx() : buildDecksListCtx()); 2497 } 2498 } 2499 } 2500 2501 function wireEvents() { 2502 if (!root) return; 2503 2504 wireAvatarFallback(root); 2505 2506 var viewRow = root.querySelector('#lb-views'); 2507 if (viewRow) { 2508 var viewSegs = viewRow.querySelectorAll('.t'); 2509 for (var v = 0; v < viewSegs.length; v++) { 2510 viewSegs[v].addEventListener('click', function (e) {
2511 setView(e.currentTarget.getAttribute('data-view')); 2512 }); 2513 } 2514 } 2515 2516 var hsortRow = root.querySelector('#lb-hsorts'); 2517 if (hsortRow) { 2518 var hsortChips = hsortRow.querySelectorAll('.chip'); 2519 for (var h = 0; h < hsortChips.length; h++) { 2520 hsortChips[h].addEventListener('click', function (e) { 2521 var key = e.currentTarget.getAttribute('data-hsort'); 2522 if (S.heroSort !== key) setState({ heroSort: key }); 2523 }); 2524 } 2525 } 2526 2527 // Hero search â kept OUTSIDE setState()/render() on every keystroke, same 2528 // reasoning as wireSearch() above: a full innerHTML re-render on every 2529 // character typed would steal focus from the input mid-word. 2530 var heroSearchInput = root.querySelector('#lb-hero-search'); 2531 if (heroSearchInput) { 2532 heroSearchInput.addEventListener('input', function () { 2533 S.heroQuery = heroSearchInput.value; 2534 var gridWrap = root.querySelector('#lb-hero-grid'); 2535 var countEl = root.querySelector('.lb-heroes-count'); 2536 if (!gridWrap) return; 2537 var filtered = S.heroes.filter(function (h) { 2538 return window.TDTT.lb.matchesHeroQuery(h.characterName, S.heroQuery); 2539 }); 2540 var sorted = window.TDTT.lb.sortHeroes(filtered, S.heroSort); 2541 var maxPicks = 0; 2542 for (var mp = 0; mp < S.heroes.length; mp++) { 2543 if (S.heroes[mp].picks > maxPicks) maxPicks = S.heroes[mp].picks; 2544 } 2545 var cards = ''; 2546 for (var ci = 0; ci < sorted.length; ci++) { 2547 cards += heroCardHtml(sorted[ci], ci + 1, maxPicks); 2548 } 2549 gridWrap.innerHTML = cards; 2550 wireHeroGridTriggers(gridWrap); 2551 if (countEl) { 2552 countEl.textContent = 'Showing ' + sorted.length + ' of ' + S.heroes.length + ' heroes'; 2553 } 2554 }); 2555 } 2556 2557 wireHeroGridTriggers(root); 2558 2559 var heroRetry = root.querySelector('#lb-hero-retry'); 2560 if (heroRetry) { 2561 heroRetry.addEventListener('click', function () { 2562 loadHeroes(); 2563 }); 2564 } 2565 2566 var trendWindowRow = root.querySelector('#lb-hero-trend-window'); 2567 if (trendWindowRow) { 2568 var trendBtns = trendWindowRow.querySelectorAll('.t'); 2569 for (var tw = 0; tw < trendBtns.length; tw++) { 2570 trendBtns[tw].addEventListener('click', function (e) { 2571 setHeroTrendWindow(e.currentTarget.getAttribute('data-trend-window')); 2572 }); 2573 } 2574 } 2575 2576 wireDelegatedModules(); 2577 2578 var gameRail = root.querySelector('[data-game-rail]'); 2579 if (gameRail) { 2580 var gameChips = gameRail.querySelectorAll('[data-game]'); 2581 for (var i = 0; i < gameChips.length; i++) { 2582 gameChips[i].addEventListener('click', function (e) { 2583 setFilter('game', e.currentTarget.getAttribute('data-game')); 2584 }); 2585 } 2586 } 2587 2588 // Scope now lives on the banner as .chip buttons, not a .seg-slab â see 2589 // bannerHtml(). Same id, different child selector. 2590 var scopeRow = root.querySelector('#lb-scopes'); 2591 if (scopeRow) { 2592 var scopeChipsEls = scopeRow.querySelectorAll('.chip'); 2593 for (var j = 0; j < scopeChipsEls.length; j++) { 2594 scopeChipsEls[j].addEventListener('click', function (e) { 2595 setFilter('scope', e.currentTarget.getAttribute('data-scope')); 2596 }); 2597 } 2598 } 2599 2600 var sortRow = root.querySelector('#lb-sorts'); 2601 if (sortRow) { 2602 var sortChips = sortRow.querySelectorAll('.chip'); 2603 for (var k = 0; k < sortChips.length; k++) { 2604 sortChips[k].addEventListener('click', function (e) { 2605 setFilter('sort', e.currentTarget.getAttribute('data-sort')); 2606 }); 2607 } 2608 } 2609 2610 var regionSel = root.querySelector('#lb-region'); 2611 if (regionSel) { 2612 regionSel.addEventListener('change', function () { 2613 setFilter('region', regionSel.value || null); 2614 }); 2615 } 2616 2617 // Task 8 â format select (Players tab). 2618 var formatSel = root.querySelector('#lb-format'); 2619 if (formatSel) { 2620 formatSel.addEventListener('change', function () { 2621 setFilter('format', formatSel.value || ''); 2622 }); 2623 } 2624 2625 // Task 8 â a Favorite Deck cell OR a Top Archetypes rail row click. Both 2626 // lb-tcg-rail.js and this file's own favoriteDeckCellHtml() mark their 2627 // clickable element `[data-deck-key]`; neither module has its own 2628 // `wire()`, so every click is delegated here generically, same technique 2629 // as wireHeroGridTriggers()'s `[data-hero-slug]`. 2630 var deckKeyEls = root.querySelectorAll('[data-deck-key]'); 2631 for (var dk = 0; dk < deckKeyEls.length; dk++) { 2632 deckKeyEls[dk].addEventListener('click', function (e) { 2633 openTcgDeck(e.currentTarget.getAttribute('data-deck-key')); 2634 }); 2635 } 2636 2637 // Task 8 â a format card's "View Leaderboard" click (Formats tab body, 2638 // window.TDTT.lbFormats â no `wire()` of its own, same delegation as above). 2639 var viewFormatEls = root.querySelectorAll('[data-view-format]'); 2640 for (var vf = 0; vf < viewFormatEls.length; vf++) { 2641 viewFormatEls[vf].addEventListener('click', function (e) { 2642 onSelectFormatFromFormats(e.currentTarget.getAttribute('data-view-format')); 2643 }); 2644 } 2645 2646 // Task 8 â the Formats rail's own window seg-tabs (7D/30D/90D/ALL). 2647 var formatsWindowRow = root.querySelector('#lb-formats-window'); 2648 if (formatsWindowRow) { 2649 var formatsWindowBtns = formatsWindowRow.querySelectorAll('.t'); 2650 for (var fw = 0; fw < formatsWindowBtns.length; fw++) { 2651 formatsWindowBtns[fw].addEventListener('click', function (e) { 2652 onFormatsWindow(e.currentTarget.getAttribute('data-formats-window')); 2653 }); 2654 } 2655 } 2656 2657 wireSearch(root.querySelector('#lb-search'), root.querySelector('#lb-search-results')); 2658 2659 var myPlaceBtn = root.querySelector('#lb-myplace'); 2660 if (myPlaceBtn) { 2661 myPlaceBtn.addEventListener('click', function () { 2662 showMyPlace(); 2663 }); 2664 } 2665 2666 var pagerEl = root.querySelector('.tdtt-pager'); 2667 if (pagerEl) { 2668 var pageBtns = pagerEl.querySelectorAll('.tdtt-pager__page'); 2669 for (var pg = 0; pg < pageBtns.length; pg++) { 2670 pageBtns[pg].addEventListener('click', function (e) { 2671 if (e.currentTarget.disabled) return; 2672 goToPage(parseInt(e.currentTarget.getAttribute('data-page'), 10)); 2673 }); 2674 } 2675 } 2676 2677 var retryBtn = root.querySelector('#lb-retry'); 2678 if (retryBtn) { 2679 retryBtn.addEventListener('click', function () { 2680 load(false); 2681 }); 2682 } 2683 2684 wirePlayerOpenTriggers(root); 2685
2686 var overlay = root.querySelector('#lb-profile-overlay'); 2687 if (overlay) { 2688 overlay.addEventListener('click', function (e) { 2689 if (e.target === overlay) closePlayerProfile(); // backdrop only, not the panel itself 2690 }); 2691 } 2692 2693 var profileViewFull = root.querySelector('#lb-profile-viewfull'); 2694 if (profileViewFull) { 2695 profileViewFull.addEventListener('click', function () { 2696 viewFullProfile(profileViewFull.getAttribute('data-pid')); 2697 }); 2698 } 2699 2700 var profileClose = root.querySelector('#lb-profile-close'); 2701 if (profileClose) { 2702 profileClose.addEventListener('click', function () { 2703 closePlayerProfile(); 2704 }); 2705 } 2706 2707 var profileRetry = root.querySelector('#lb-profile-retry'); 2708 if (profileRetry) { 2709 profileRetry.addEventListener('click', function () { 2710 openPlayerProfile(S.openPlayerId); 2711 }); 2712 } 2713 2714 var gameHeads = root.querySelectorAll('.lb-profile-game-head'); 2715 for (var m = 0; m < gameHeads.length; m++) { 2716 gameHeads[m].addEventListener('click', function (e) { 2717 var gid = e.currentTarget.getAttribute('data-game'); 2718 setState({ profileOpenGame: S.profileOpenGame === gid ? null : gid }); 2719 }); 2720 } 2721 } 2722 2723 function loadRegions() { 2724 window.TDTT.lb 2725 .regions() 2726 .then(function (data) { 2727 if (Array.isArray(data)) { 2728 regionGroups = data; 2729 render(); 2730 } 2731 }) 2732 .catch(function () { 2733 /* region filter degrades to "All Regions" only â not fatal */ 2734 }); 2735 } 2736 2737 /* Re-fetch and re-render. `reset` returns to page 1 â every filter change 2738 * must pass true, or the pager keeps showing whatever page the previous 2739 * filter's results left it on. Always REPLACES S.rows (never concatenates): 2740 * navigation is page-based via the .tdtt-pager buttons (goToPage), not an 2741 * accumulating "Load more" list. */ 2742 function load(reset) { 2743 if (reset) { 2744 S.offset = 0; 2745 S.rows = []; 2746 } 2747 setState({ loading: true, error: null }); 2748 var mine = ++loadSeq; 2749 return window.TDTT.lb 2750 .players({ 2751 game: S.game, 2752 scope: S.scope, 2753 region: S.region, 2754 sort: S.sort, 2755 limit: S.limit, 2756 offset: S.offset, 2757 paged: true, 2758 // In-format ranking (Task 8) is a ranked-scope-only mode server-side 2759 // â never sent for the tournament/throne-room scopes, same gate 2760 // currentFormatMeta() applies. 2761 format: S.scope === 'ranked' ? S.format : undefined, 2762 }) 2763 .then(function (r) { 2764 if (mine !== loadSeq) return; // stale â superseded by a newer load (e.g. after a remount) 2765 setState({ 2766 rows: r.rows, 2767 total: r.total, 2768 totals: r.totals, 2769 loading: false, 2770 }); 2771 }) 2772 .catch(function (e) { 2773 if (mine !== loadSeq) return; 2774 var msg = 2775 e && e.status === 401 ? 'Sign in to view this leaderboard.' : (e && e.message) || 'Failed to load'; 2776 setState({ loading: false, error: msg }); 2777 }); 2778 } 2779 2780 // ââ TCG Players-tab rail + Formats tab (Task 8) âââââââââââââââââââââââââââââ 2781 // leaderboard.js owns every fetch these two delegated modules need and 2782 // builds the `ctx` objects they render from â same delegation seam as the 2783 // hero page / Drafts-Bans above. Neither module has a `wire()` â every 2784 // click inside their markup is wired generically in wireEvents() via 2785 // data-* attributes (`[data-deck-key]`, `[data-view-format]`, 2786 // `[data-formats-window]`), the same delegated-click technique 2787 // wireHeroGridTriggers()/wirePlayerOpenTriggers() already use. 2788 2789 // window.TDTT.lbTcgRail (lb-tcg-rail.js) renders from this â ALWAYS the 2790 // all-time, all-format picture (window 'all'), independent of S.format/ 2791 // S.offset, per the brief. 2792 function loadTcgRail() { 2793 var game = S.game; 2794 var seq = ++tcgRailSeq; 2795 S.tcgRail.game = game; 2796 S.tcgRail.decksState = 'loading'; 2797 S.tcgRail.tournamentsState = 'loading'; 2798 render(); 2799 window.TDTT.lb 2800 .decks(game, 'all') 2801 .then(function (data) { 2802 if (seq !== tcgRailSeq) return; 2803 if (data && Array.isArray(data.decks)) { 2804 S.tcgRail.decks = data.decks; 2805 S.tcgRail.decksState = 'ready'; 2806 } else { 2807 S.tcgRail.decksState = 'error'; 2808 } 2809 render(); 2810 }) 2811 .catch(function () { 2812 if (seq !== tcgRailSeq) return; 2813 S.tcgRail.decksState = 'error'; 2814 render(); 2815 }); 2816 window.TDTT.lb 2817 .recentTournaments(game) 2818 .then(function (data) { 2819 if (seq !== tcgRailSeq) return; 2820 if (data && Array.isArray(data.tournaments)) { 2821 S.tcgRail.tournaments = data.tournaments; 2822 S.tcgRail.tournamentsState = 'ready'; 2823 } else { 2824 S.tcgRail.tournamentsState = 'error'; 2825 } 2826 render(); 2827 }) 2828 .catch(function () { 2829 if (seq !== tcgRailSeq) return; 2830 S.tcgRail.tournamentsState = 'error'; 2831 render(); 2832 }); 2833 window.TDTT.lb 2834 .formats(game, 'all') 2835 .then(function (data) { 2836 if (seq !== tcgRailSeq) return; 2837 S.tcgRail.formats = data && Array.isArray(data.formats) ? data.formats : undefined; 2838 render(); 2839 }) 2840 .catch(function () { 2841 if (seq !== tcgRailSeq) return; 2842 S.tcgRail.formats = undefined; 2843 render(); 2844 }); 2845 } 2846 2847 // Re-entering Players (ranked, a TCG game) for the SAME game the rail was 2848 // already fetched for is a no-op â same "if stale" convention as 2849 // loadHeroTrendIfStale(). 2850 function loadTcgRailIfStale() { 2851 if (S.tcgRail.game !== S.game) loadTcgRail(); 2852 } 2853 2854 // A Favorite Deck cell / Top Archetypes row click, when `key` is set â also 2855 // every deck-key trigger INSIDE the Decks tab itself (a grid card, a
2856 // matchup row, a public recent-game opponent), all delegated through the 2857 // same generic `[data-deck-key]` handler in wireEvents(). Switches the 2858 // whole view to Decks and opens that archetype's page, deep-linked via 2859 // `?deck=<key>` â mirrors desktop's handleOpenDeck (Task 9). 2860 function openTcgDeck(key) { 2861 if (!key) return; 2862 S.view = 'decks'; 2863 S.deckKey = key; 2864 S.deckPage.detailState = 'loading'; 2865 S.deckPage.detail = null; 2866 S.deckPage.matchupsState = 'loading'; 2867 S.deckPage.matchups = null; 2868 S.deckPage.gamesState = 'loading'; 2869 S.deckPage.games = null; 2870 syncUrl(); 2871 render(); 2872 loadDeckPage(key); 2873 } 2874 2875 function buildTcgRailCtx() { 2876 return { 2877 game: S.game, 2878 gameName: window.TDTT.game.gameLabel(S.game), 2879 decksState: S.tcgRail.decksState, 2880 decks: S.tcgRail.decks, 2881 tournamentsState: S.tcgRail.tournamentsState, 2882 tournaments: S.tcgRail.tournaments, 2883 formats: S.tcgRail.formats, 2884 }; 2885 } 2886 2887 // Guards for lb-tcg-rail.js not being loaded yet, same convention as the 2888 // Task 10/11 delegation below. 2889 function tcgRailAsideHtml() { 2890 var mod = window.TDTT && window.TDTT.lbTcgRail; 2891 var html = mod && typeof mod.html === 'function' ? mod.html(buildTcgRailCtx()) : ''; 2892 return '<aside class="lb-tcg-aside">' + html + '</aside>'; 2893 } 2894 2895 // The Players view for a TCG game (ranked scope) gets a two-column layout 2896 // â the ranked table beside the insights rail â same shape as the Heroes 2897 // landing grid + its aside (`.lb-tcg-layout`/`.lb-tcg-main`/`.lb-tcg-aside` 2898 // mirror `.lb-heroes-layout`/`.lb-heroes-main`/`.lb-heroes-aside`). Every 2899 // other Players case (Dice Throne, or a non-ranked scope) renders exactly 2900 // as before â single column, no rail. 2901 function playersBodyHtml() { 2902 if (showsTcgFeatures() && S.scope === 'ranked') { 2903 return ( 2904 '<div class="lb-tcg-layout"><div class="lb-tcg-main">' + listHtml() + '</div>' + 2905 tcgRailAsideHtml() + '</div>' 2906 ); 2907 } 2908 return listHtml(); 2909 } 2910 2911 // ââ Formats tab (Task 8) âââââââââââââââââââââââââââââââââââââââââââââââââ 2912 function loadFormatsTab(w) { 2913 var game = S.game; 2914 var win = w || S.formatsTab.window; 2915 var seq = ++formatsSeq; 2916 S.formatsTab.game = game; 2917 S.formatsTab.window = win; 2918 S.formatsTab.state = 'loading'; 2919 render(); 2920 window.TDTT.lb 2921 .formats(game, win) 2922 .then(function (payload) { 2923 if (seq !== formatsSeq) return; 2924 S.formatsTab.data = payload && Array.isArray(payload.formats) ? payload.formats : []; 2925 S.formatsTab.state = 'ready'; 2926 render(); 2927 }) 2928 .catch(function () { 2929 if (seq !== formatsSeq) return; 2930 S.formatsTab.state = 'error'; 2931 render(); 2932 }); 2933 } 2934 2935 // Re-entering the Formats tab for the SAME game is a no-op â same 2936 // "if stale" convention as loadTcgRailIfStale()/loadHeroTrendIfStale(). 2937 function loadFormatsTabIfStale() { 2938 if (S.formatsTab.game !== S.game) loadFormatsTab(S.formatsTab.window); 2939 } 2940 2941 function onFormatsWindow(w) { 2942 if (S.formatsTab.window === w) return; 2943 loadFormatsTab(w); 2944 } 2945 2946 // A format card's "View Leaderboard" click â switches the Players tab to 2947 // this format, mirroring desktop's FormatsView onSelectFormat prop. 2948 function onSelectFormatFromFormats(formatId) { 2949 S.format = formatId; 2950 setView('players'); 2951 } 2952 2953 function buildFormatsCtx() { 2954 var meta = window.TDTT.game.TABLE[S.game]; 2955 return { 2956 game: S.game, 2957 windowVal: S.formatsTab.window, 2958 state: S.formatsTab.state, 2959 formats: S.formatsTab.data, 2960 art: meta ? '/m/logos/' + meta.logoFile : '', 2961 }; 2962 } 2963 2964 function formatsBodyHtml() { 2965 var mod = window.TDTT && window.TDTT.lbFormats; 2966 if (!mod || typeof mod.render !== 'function') return loadingHtml(); 2967 return mod.render(buildFormatsCtx()); 2968 } 2969 2970 // ââ Decks tab + archetype detail page (Task 9) ââââââââââââââââââââââââââ 2971 // window.TDTT.lbDecksView (lb-decks.js) renders/wires from the ctx builders 2972 // below; this file owns every fetch (decks(), deckDetail(), deckMatchups(), 2973 // deckGames()). Every fetch is guarded by acceptResponse()/S.decksTab.token 2974 // or S.deckPage.token so a late response for a game/window/format/key 2975 // that's since changed is ignored (Review Focus 5). 2976 2977 function loadDecksGrid() { 2978 var game = S.game; 2979 var win = S.decksTab.window; 2980 var fmt = S.decksTab.format; 2981 var mine = ++S.decksTab.token; 2982 S.decksTab.gridState = 'loading'; 2983 render(); 2984 window.TDTT.lb 2985 .decks(game, win, fmt || undefined) 2986 .then(function (data) { 2987 if (!acceptResponse(S.decksTab, mine)) return; 2988 if (data && Array.isArray(data.decks) && data.totals) { 2989 S.decksTab.decks = data.decks; 2990 S.decksTab.totals = data.totals; 2991 S.decksTab.gridState = 'ready'; 2992 } else { 2993 S.decksTab.gridState = 'error'; 2994 } 2995 S.decksTab.game = game; 2996 S.decksTab.fetchedWindow = win; 2997 S.decksTab.fetchedFormat = fmt; 2998 render(); 2999 }) 3000 .catch(function () { 3001 if (!acceptResponse(S.decksTab, mine)) return; 3002 S.decksTab.gridState = 'error'; 3003 S.decksTab.game = game; 3004 S.decksTab.fetchedWindow = win; 3005 S.decksTab.fetchedFormat = fmt; 3006 render(); 3007 }); 3008 } 3009
3010 // Re-entering the Decks tab (or closing the archetype page) when the grid 3011 // already carries data for the CURRENT game/window/format is a no-op â 3012 // same "if stale" convention as loadTcgRailIfStale()/loadFormatsTabIfStale(). 3013 function loadDecksGridIfStale() { 3014 if ( 3015 S.decksTab.game !== S.game || 3016 S.decksTab.fetchedWindow !== S.decksTab.window || 3017 S.decksTab.fetchedFormat !== S.decksTab.format 3018 ) { 3019 loadDecksGrid(); 3020 } 3021 } 3022 3023 // Shared by the grid AND the archetype page (batch ruling: one window 3024 // state above both). Only the currently-visible half refetches â the other 3025 // picks up the new window value next time it becomes stale. 3026 function onDecksWindow(w) { 3027 if (S.decksTab.window === w) return; 3028 S.decksTab.window = w; 3029 if (S.deckKey) { 3030 loadDeckPage(S.deckKey); 3031 } else { 3032 loadDecksGrid(); 3033 } 3034 } 3035 3036 function onDecksFormat(fmt) { 3037 if (S.decksTab.format === fmt) return; 3038 S.decksTab.format = fmt; 3039 loadDecksGrid(); 3040 } 3041 3042 // Sort/search are purely client-side (window.TDTT.lbDecks.sortDecks/ 3043 // matchesDeckQuery over the already-fetched S.decksTab.decks) â neither 3044 // triggers a fetch. setDecksSort() re-renders (a button click, no focus to 3045 // lose); setDecksQuery() deliberately does NOT call render() â lb-decks.js's 3046 // own wire() patches the grid locally on every keystroke to avoid stealing 3047 // input focus, and only persists the value here for later window/format 3048 // changes to read. 3049 function setDecksSort(k) { 3050 if (S.decksTab.sort === k) return; 3051 S.decksTab.sort = k; 3052 render(); 3053 } 3054 3055 function setDecksQuery(q) { 3056 S.decksTab.query = q || ''; 3057 } 3058 3059 function buildDecksListCtx() { 3060 return { 3061 game: S.game, 3062 gameName: window.TDTT.game.gameLabel(S.game), 3063 window: S.decksTab.window, 3064 format: S.decksTab.format, 3065 formats: window.TDTT.decks ? window.TDTT.decks.deckFormatOptions(S.game) : [], 3066 sort: S.decksTab.sort, 3067 query: S.decksTab.query, 3068 gridState: S.decksTab.gridState, 3069 decks: S.decksTab.decks, 3070 totals: S.decksTab.totals, 3071 railHtml: tcgRailAsideHtml(), 3072 onWindow: onDecksWindow, 3073 onFormat: onDecksFormat, 3074 onSort: setDecksSort, 3075 onQuery: setDecksQuery, 3076 onSelectDeck: openTcgDeck, 3077 }; 3078 } 3079 3080 // The three-fetch chain for one archetype: detail first (its 404/notFound 3081 // gates the other two â an archetype that isn't public has no matchups or 3082 // recent games to show either, and firing those anyway would just race a 3083 // 404 of their own for no benefit, mirrors desktop's DeckArchetypePage), 3084 // then matchups + recent games in parallel once it resolves 'ready'.
3085 function loadDeckPage(key) { 3086 var game = S.game; 3087 var win = S.decksTab.window; 3088 var mine = ++S.deckPage.token; 3089 S.deckPage.detailState = 'loading'; 3090 S.deckPage.detail = null; 3091 S.deckPage.matchupsState = 'loading'; 3092 S.deckPage.matchups = null; 3093 S.deckPage.gamesState = 'loading'; 3094 S.deckPage.games = null; 3095 render(); 3096 window.TDTT.lb 3097 .deckDetail(game, key, win) 3098 .then(function (data) { 3099 if (!acceptResponse(S.deckPage, mine)) return; 3100 S.deckPage.detail = data; 3101 S.deckPage.detailState = 'ready'; 3102 render(); 3103 3104 window.TDTT.lb 3105 .deckMatchups(game, key, win) 3106 .then(function (list) { 3107 if (!acceptResponse(S.deckPage, mine)) return; 3108 S.deckPage.matchups = Array.isArray(list) ? list : []; 3109 S.deckPage.matchupsState = 'ready'; 3110 render(); 3111 }) 3112 .catch(function () { 3113 if (!acceptResponse(S.deckPage, mine)) return; 3114 S.deckPage.matchupsState = 'error'; 3115 render(); 3116 }); 3117 3118 window.TDTT.lb 3119 .deckGames(game, key, win) 3120 .then(function (payload) { 3121 if (!acceptResponse(S.deckPage, mine)) return; 3122 S.deckPage.games = (payload && Array.isArray(payload.games)) ? payload.games : []; 3123 S.deckPage.gamesState = 'ready'; 3124 render(); 3125 }) 3126 .catch(function () { 3127 if (!acceptResponse(S.deckPage, mine)) return; 3128 S.deckPage.gamesState = 'error'; 3129 render(); 3130 }); 3131 }) 3132 .catch(function (e) { 3133 if (!acceptResponse(S.deckPage, mine)) return; 3134 S.deckPage.detailState = e && e.status === 404 ? 'notFound' : 'error'; 3135 render(); 3136 }); 3137 } 3138 3139 function buildDeckPageCtx() { 3140 var dp = S.deckPage; 3141 return { 3142 game: S.game, 3143 window: S.decksTab.window, 3144 detailState: dp.detailState, 3145 detail: dp.detail, 3146 matchupsState: dp.matchupsState, 3147 matchups: dp.matchups, 3148 gamesState: dp.gamesState, 3149 games: dp.games, 3150 avatarHtml: avatarHtml, 3151 onWindow: onDecksWindow, 3152 onLeaderboards: function () { 3153 setView('players'); 3154 }, 3155 onBack: closeDeckPage, 3156 onOpenPlayer: viewFullProfile, 3157 }; 3158 } 3159 3160 // "Decks" breadcrumb / a page's Back button â closes the archetype page and 3161 // returns to the grid, refetching only if the grid's own game/window/format 3162 // has since gone stale (e.g. the window was changed while the page was 3163 // open). 3164 function closeDeckPage() { 3165 S.deckKey = null; 3166 S.deckPage.token++; 3167 syncUrl(); 3168 render(); 3169 loadDecksGridIfStale(); 3170 } 3171 3172 function decksBodyHtml() { 3173 var mod = window.TDTT && window.TDTT.lbDecksView; 3174 if (!mod) return loadingHtml(); 3175 if (S.deckKey) { 3176 return typeof mod.pageHtml === 'function' ? mod.pageHtml(buildDeckPageCtx()) : loadingHtml(); 3177 } 3178 return typeof mod.listHtml === 'function' ? mod.listHtml(buildDecksListCtx()) : loadingHtml(); 3179 } 3180 3181 // Guards for the Task 10/11 modules not being loaded yet â see this file's 3182 // top-of-file comment. A missing module renders loadingHtml() rather than 3183 // throwing, so this file works standalone before either task ships. 3184 function heroPageBodyHtml() { 3185 var mod = window.TDTT && window.TDTT.lbHeroPage; 3186 if (!mod || typeof mod.html !== 'function') return loadingHtml(); 3187 return mod.html(buildHeroPageCtx()); 3188 } 3189 3190 function draftsBodyHtml() { 3191 var mod = window.TDTT && window.TDTT.lbDraftsBans; 3192 if (!mod || typeof mod.draftsHtml !== 'function') return loadingHtml(); 3193 return mod.draftsHtml(buildDraftsBansCtx()); 3194 } 3195 3196 function bansBodyHtml() { 3197 var mod = window.TDTT && window.TDTT.lbDraftsBans; 3198 if (!mod || typeof mod.bansHtml !== 'function') return loadingHtml(); 3199 return mod.bansHtml(buildDraftsBansCtx()); 3200 } 3201 3202 function bodyHtml() { 3203 if (S.view === 'heroes') return S.heroSlug ? heroPageBodyHtml() : heroesLandingHtml(); 3204 if (S.view === 'drafts') return draftsBodyHtml(); 3205 if (S.view === 'bans') return bansBodyHtml();
3206 if (S.view === 'formats') return formatsBodyHtml(); 3207 if (S.view === 'decks') return decksBodyHtml(); 3208 return playersBodyHtml(); 3209 } 3210 3211 // E4.1: this section now renders ONLY its main column (no `.tdtt-aside` 3212 // sibling here anymore â see rankInfoRowHtml()'s comment above). The right 3213 // rail is shell chrome, rendered once by app-shell.js outside 3214 // #app-content, identical on every page. 3215 // Drop-in for `root.innerHTML = html` that keeps the existing banner node 3216 // (see stable-banner.js); plain assignment if that script is absent. 3217 function setRootHtml(html) { 3218 if (window.TDTT && window.TDTT.stableBanner) window.TDTT.stableBanner.setHtml(root, html); 3219 else root.innerHTML = html; 3220 } 3221 3222 function render() { 3223 if (!root) return; 3224 setRootHtml( 3225 '<div class="lb-main">' + 3226 bannerHtml() + summaryStatsHtml() + rankInfoRowHtml() + filterBarHtml() + bodyHtml() + 3227 '</div>' + 3228 profilePanelHtml() 3229 ); 3230 wireEvents(); 3231 // The rebuild above discarded whatever the game rail was wired to â 3232 // destroy that handle before wiring the fresh one. 3233 if (railWire) railWire.destroy(); 3234 railWire = window.TDTT && window.TDTT.gameRail ? window.TDTT.gameRail.wire(root) : null; 3235 if (window.TDTT && window.TDTT.bannerFx) window.TDTT.bannerFx.refresh(); 3236 } 3237 3238 window.TDTTLeaderboard = { 3239 mount: function (el) { 3240 root = el; 3241 // Step 1 of the brief: read `?view=`/`?hero=` before the first render, 3242 // exactly the try/catch shape profile.js's viewedPlayerId() uses (see 3243 // readUrlState()) â a malformed location.search degrades to Players 3244 // rather than throwing. 3245 applyInitialUrlState(); 3246 loadRegions(); 3247 loadMyRank(); 3248 if (S.view === 'players') { 3249 load(true); 3250 if (S.scope === 'ranked' && showsTcgFeatures()) loadTcgRailIfStale(); 3251 } else { 3252 // The player list is off screen for every other initial view â mark 3253 // it stale rather than spending a request on rows nobody is looking 3254 // at (setView()/onPopState() reload it on return to Players). 3255 S.playersDirty = true; 3256 S.rows = []; 3257 S.offset = 0; 3258 render(); 3259 if (S.view === 'heroes') { 3260 if (S.heroSlug) { 3261 fetchHeroPage(S.heroSlug); 3262 } else { 3263 loadHeroes(); 3264 loadHeroTrend(); 3265 } 3266 } else if (S.view === 'drafts') { 3267 loadDraftsData(); 3268 } else if (S.view === 'bans') { 3269 loadBansData(); 3270 } else if (S.view === 'formats') { 3271 loadFormatsTab(S.formatsTab.window); 3272 } else if (S.view === 'decks') { 3273 loadTcgRailIfStale(); 3274 if (S.deckKey) { 3275 loadDeckPage(S.deckKey); 3276 } else { 3277 loadDecksGridIfStale(); 3278 } 3279 } 3280 } 3281 // Outside #app-content, so unmount() MUST remove this â same reasoning 3282 // as profile.js's onTabPopState (router.js's own popstate listener only 3283 // re-renders when the SECTION changes, never for an in-section view 3284 // change like this one). 3285 window.addEventListener('popstate', onPopState); 3286 // Wired once ever (guarded), rather than in wireEvents() (which reruns 3287 // on every render) or unconditionally here. mount() runs again every 3288 // time the router returns to this section, so without the guard each 3289 // visit would stack another document-level Escape handler. 3290 if (!escWired) { 3291 escWired = true; 3292 document.addEventListener('keydown', function (e) { 3293 if (e.key === 'Escape' && S.openPlayerId) closePlayerProfile(); 3294 }); 3295 } 3296 }, 3297 // The router empties the container itself; this drops the state that would 3298 // otherwise be reused by the next mount, and invalidates every in-flight 3299 // response so a slow fetch cannot paint into a section the user has left. 3300 unmount: function () { 3301 searchSeq++; 3302 profileSeq++; 3303 loadSeq++; 3304 myRankSeq++; 3305 heroPageSeq++; 3306 heroPageWindowSeq++; 3307 heroTrendSeq++; 3308 draftsBansSeq++; 3309 tcgRailSeq++; 3310 formatsSeq++; 3311 S.decksTab.token++; 3312 S.deckPage.token++; 3313 if (searchTimer) { 3314 clearTimeout(searchTimer); 3315 searchTimer = null; 3316 } 3317 if (railWire) { 3318 railWire.destroy(); 3319 railWire = null; 3320 } 3321 window.removeEventListener('popstate', onPopState); 3322 root = null; 3323 S.openPlayerId = null; 3324 S.profile = null; 3325 S.heroSlug = null; 3326 S.heroPage = null; 3327 S.deckKey = null; 3328 S.myPlaceStatus = null; 3329 S.myRank = null; 3330 }, 3331 _state: S, // read-only handle for browser checks 3332 }; 3333 3334 window.TDTT = window.TDTT || {}; 3335 window.TDTT.appSections = window.TDTT.appSections || {}; 3336 window.TDTT.appSections.leaderboard = { 3337 mount: window.TDTTLeaderboard.mount, 3338 unmount: window.TDTTLeaderboard.unmount, 3339 }; 3340})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.