1/* My Profile section. Renders the signed-in player's own PublicProfile. 2 * All data goes through window.TDTT.lb. 3 * 4 * E4.5: gained a tab strip â Overview and Achievements. The Achievements 5 * catalog and its fetch pair live ENTIRELY in this file, not in 6 * public/shared/profile-render.js. 7 * 8 * Phase 2c Task 2: the Overview tab moved off profile-render.js's 9 * contentHtml() onto window.TDTT.profileOverview (public/shared/ 10 * profile-overview.js's nine-card renderer) â see overviewPanelHtml() below 11 * for why. profile-render.js is UNCHANGED by that move: it still renders 12 * leaderboard.js's player drill-down (for OTHER players' cards) exactly as 13 * before, and putting the tab strip or the achievements catalog inside 14 * contentHtml() would still leak both onto every drill-down, same as it 15 * always would have. 16 * 17 * Phase 4b Task 1: the two-tab strip becomes the same six-tab model desktop 18 * got in Phase 4a -- profileTabsFor()/resolveProfileTab() (window.TDTT. 19 * profileCore, generated from packages/shared/src/profile/tabs.ts) are now 20 * the ONE source of truth for which tabs exist, their labels and the 21 * ownerOnly rule. This file layers exactly one thing on top of that: which 22 * of those ids /app actually has a panel for -- see IMPLEMENTED_TAB_IDS 23 * below and docs/superpowers/plans/2026-09-21-user-profile-phase4b-app-tabs.md's 24 * "Collection is deliberately not built here" section. Game Stats, Heroes 25 * and Match History get their real panel bodies in Tasks 2 and 3; this task 26 * only owns the tab list, ?tab= resolution and the lazy-fetch-and-cache-on-S 27 * plumbing (see loadTabData()/ensureTabLoaded() below), so their bodies are 28 * placeholders for now. */ 29(function () { 30 'use strict'; 31 32 var root = null; 33 var seq = 0; 34 var achvSeq = 0; 35 // Phase B1 Task 6 -- the Decks tab owns its own fetch/render lifecycle via 36 // window.TDTT.profileDecks.mount() (public/shared/profile-decks.js), 37 // unlike the other tabs, which render from S inside this file's own 38 // render(). decksMount holds the { destroy } handle from the most recent 39 // mount() call so render() can tear it down before every rebuild of 40 // root.innerHTML (which would otherwise leave it pointing at a detached 41 // #pf-decks-root) and unmount() can tear it down when this whole section 42 // unmounts. 43 var decksMount = null; 44 var S = { 45 profile: null, 46 loading: false, 47 error: null, 48 activeTab: 'overview', 49 // status: 'idle' (never fetched â the tab hasn't been opened yet) | 50 // 'loading' | 'ready' | 'error'. defs/rows are only trusted when 51 // status === 'ready', and are always set/cleared together â they come 52 // from one Promise.all so the "N of M" header and the list can never 53 // disagree (the same reason the old rail card paired the two fetches). 54 achv: { status: 'idle', defs: null, rows: null, error: null }, 55 // Phase 5b Task 3 -- UI-only state for the Achievements tab's category 56 // filter / search / sort controls. Deliberately separate from S.achv: 57 // this never triggers a refetch (no seq counter of its own) -- it only 58 // changes what achievementsCatalogHtml() computes from the already- 59 // cached S.achv.defs/S.achv.rows. category: null means "All". 60 achvUi: { category: null, query: '', sort: 'progress' }, 61 // Game Stats / Heroes / Match History (Phase 4b Task 1) -- one 62 // idle/loading/ready/error slot per lazily-loaded tab, same shape as 63 // S.achv above, keyed by the tab id. Populated by loadTabData() below. 64 tabData: { 65 'game-stats': emptyTabState(), 66 heroes: emptyTabState(), 67 'match-history': emptyTabState(), 68 }, 69 // Phase 4b Task 2 -- which game the Game Stats panel's switcher has 70 // selected. 'ALL' means Overall. Pure client-side UI state: never 71 // fetched, never invalidated by a seq counter, because switching it 72 // re-renders from data S.tabData['game-stats']/S.profile already hold -- 73 // see window.TDTT.profilePanels.gameStatsHtml()'s header comment. 74 gameStatsGame: 'ALL', 75 // Phase 6 Task 3 -- the Heroes tab's sort/filter/page/selection state, 76 // threaded straight through to window.TDTT.profilePanels.heroesHtml() as 77 // its ui argument. Same "pure client-side UI state, re-renders from 78 // data already held" contract as S.gameStatsGame/S.achvUi above -- see 79 // heroesHtml()'s own header comment (profile-panels.js) and
80 // onHeroesControlsClick() below for the transitions. 81 heroesUi: { sort: 'games', archetype: null, page: 1, selectedSlug: null }, 82 }; 83 84 // The ids /app has a real panel for. profileTabsFor(isOwn)/resolveProfileTab 85 // (window.TDTT.profileCore) are the shared source of truth for which tabs 86 // EXIST and the ownerOnly rule -- this list is layered on top of that to 87 // drop ids this surface does not implement. Collection is deliberately 88 // absent: it is desktop's owner-editable 44-hero grid; /app's Overview 89 // already shows the owned count read-only (profile-overview.js's 90 // collectionCardHtml()). See docs/superpowers/plans/ 91 // 2026-09-21-user-profile-phase4b-app-tabs.md's "Collection is 92 // deliberately not built here" section. Do NOT reimplement the ownerOnly 93 // rule here -- profileTabsFor() already drops Collection for a viewer; 94 // this only ever SUBTRACTS ids on top of what that helper returns. 95 var IMPLEMENTED_TAB_IDS = [ 96 'overview', 'game-stats', 'heroes', 'achievements', 'match-history', 'decks', 97 ]; 98 99 // Tabs whose data is fetched lazily on first open and cached on 100 // S.tabData, the same contract Achievements already has (S.achv). Does 101 // NOT include 'achievements' -- that tab keeps its own pre-existing 102 // S.achv slot and loadAchievements(), untouched by this task. 103 var LAZY_DATA_TABS = ['game-stats', 'heroes', 'match-history']; 104 105 function emptyTabState() { 106 return { status: 'idle', data: null, error: null }; 107 } 108 109 function isImplementedTab(id) { 110 for (var i = 0; i < IMPLEMENTED_TAB_IDS.length; i++) { 111 if (IMPLEMENTED_TAB_IDS[i] === id) return true; 112 } 113 return false; 114 } 115 116 function isLazyDataTab(id) { 117 for (var i = 0; i < LAZY_DATA_TABS.length; i++) { 118 if (LAZY_DATA_TABS[i] === id) return true; 119 } 120 return false; 121 } 122 123 // Looks the label up from the shared PROFILE_TABS list itself, rather than 124 // keeping a second hand-written label map here, so a label can never drift 125 // between this file and profile-core.js the way the old two-tab TABS array 126 // could have. 127 function tabLabel(id) { 128 var core = (window.TDTT && window.TDTT.profileCore) || {}; 129 var tabs = core.PROFILE_TABS || []; 130 for (var i = 0; i < tabs.length; i++) { 131 if (tabs[i].id === id) return tabs[i].label; 132 } 133 return id; 134 } 135 136 var MEMBER_SINCE_MONTHS = [ 137 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 138 ]; 139 140 function playerId() { 141 try { 142 return localStorage.getItem('dta_playerId') || null; 143 } catch (e) { 144 return null; 145 } 146 } 147 148 // Phase 3, Task 1 -- who the profile is FOR, a SEPARATE question from 149 // playerId() (who is signed in). Prefers ?player=<id> when present, exactly 150 // the try/catch shape codex.js:481 readGameFromUrl() uses, so a malformed 151 // location.search degrades to the signed-in id instead of throwing. A bare 152 // /app/profile visit (no ?player=) is unchanged -- it falls through to 153 // playerId(). Both data fetches on this section use viewedPlayerId(): 154 // load()'s profile fetch AND loadAchievements()'s achievement fetch (fix 155 // round 1) -- so the Achievements tab shows the VIEWED player's progress, 156 // matching the header and Overview, not the signed-in viewer's own. 157 // achievementsPanelHtml()'s sign-in GATE is the one place that 158 // deliberately keeps calling playerId() instead -- "is anyone signed in" 159 // is a different question from "whose data is this", and conflating them 160 // is how a sign-in-gated or edit-only element leaks the wrong state onto 161 // someone else's profile. 162 function viewedPlayerId() { 163 try { 164 var p = new URLSearchParams(window.location.search).get('player'); 165 if (p) return p; 166 } catch (e) { 167 /* malformed location.search -- fall through to the signed-in id */ 168 } 169 return playerId(); 170 } 171 172 // Phase 4b Task 1 -- "is this profile mine". A SEPARATE question from 173 // viewedPlayerId() above: this reads the RAW ?player= value, not its 174 // already-defaulted-to-playerId() result, because "no ?player= at all" 175 // must count as own-profile even while signed out (matches desktop's 176 // Profile.tsx -- isOwnProfile is computed as !viewingPlayerId OR 177 // viewingPlayerId === currentPlayerId). Feeds profileTabsFor()/ 178 // resolveProfileTab() so the shared ownerOnly rule sees the same answer 179 // both surfaces would give. (No backtick punctuation or ES6 declaration 180 // keywords spelled out in this comment on purpose -- the ES5 gate greps 181 // this file raw, not comment-stripped, per profile-core.js's own header 182 // comment explaining that same rule.) 183 function isOwnProfile() { 184 var viewingId = null; 185 try { 186 viewingId = new URLSearchParams(window.location.search).get('player'); 187 } catch (e) { 188 viewingId = null; 189 } 190 return !viewingId || viewingId === playerId(); 191 } 192 193 // Raw ?tab= value, unresolved -- resolveActiveTab() below turns this into 194 // an actual tab id. Same try/catch shape as viewedPlayerId()/isOwnProfile(). 195 function readTabParam() { 196 try { 197 return new URLSearchParams(window.location.search).get('tab'); 198 } catch (e) { 199 return null; 200 } 201 } 202 203 function esc(s) { 204 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { 205 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 206 }); 207 } 208 209 // T15: a player's avatarUrl is player-supplied and may legitimately be a 210 // same-origin upload path this server's own upload route minted (e.g. 211 // `/uploads/avatars/<name>`), not only an absolute http(s) URL -- 212 // mirroring the server's isSafeAvatarUrl() allow-list 213 // (apps/server/src/http/url.ts). A pre-existing row may still hold a 214 // `javascript:`/`data:`/protocol-relative value, so any src built from it 215 // must pass through this allow-list first. 216 function safeImageUrl(u) { 217 if (typeof u !== 'string') return ''; 218 var t = u.trim(); 219 if (/^https?:\/\//i.test(t)) return t; 220 return /^\/uploads\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp|gif)$/i.test(t) ? t : ''; 221 } 222 223 // Phase 6 Task 3 -- builds the slug -> archetype map heroesHtml() needs
224 // from GET /api/encyclopedia/characters's payload (window.TDTT.codex. 225 // roster(), public/shared/codex-data.js). Desktop's HeroesPanel.tsx builds 226 // this once from the static ALL_CHARACTERS import; /app has no such 227 // import, so this derives the same map from the async roster response 228 // instead. A null/undefined roster (never resolved, or the fetch failed -- 229 // see loadTabData()'s own header comment on the non-fatal Promise.all) 230 // degrades to an empty map, never a throw -- heroesHtml() already treats 231 // an empty map as "no archetype data" and renders accordingly. 232 function archetypeBySlugFromRoster(roster) { 233 var map = {}; 234 var chars = roster && Array.isArray(roster.characters) ? roster.characters : []; 235 for (var i = 0; i < chars.length; i++) { 236 var c = chars[i] || {}; 237 if (c.slug && c.archetype) map[c.slug] = c.archetype; 238 } 239 return map; 240 } 241 242 // ââ Header (Phase 1c) â the same identity banner the desktop app got in 243 // Phase 1b (apps/desktop/src/components/profile/ProfileHeader.tsx), 244 // rendered above the tab strip. No Edit control: /app performs no writes 245 // anywhere on this surface (not a single PATCH), so there is nothing here 246 // for one to call. ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 247 248 // Formats an ISO date string as "Member since Apr 2024". Returns null 249 // (never "Invalid Date") when iso is missing or unparseable -- the caller 250 // omits the element entirely rather than rendering a blank/garbled string. 251 function formatMemberSince(iso) { 252 if (!iso) return null; 253 var d = new Date(iso); 254 if (isNaN(d.getTime())) return null; 255 return 'Member since ' + MEMBER_SINCE_MONTHS[d.getMonth()] + ' ' + d.getFullYear(); 256 } 257 258 // Same tdtt-avatar/lb-avatar-img/lb-avatar-init shape profile-render.js's 259 // own avatarHtml() renders (that helper isn't exposed on 260 // window.TDTT.profileRender, so this is a verbatim duplicate, same 261 // reasoning as that file's own header comment on duplicated helpers) -- 262 // window.TDTT.profileRender.wireAvatars(root) wires the error->fallback 263 // swap for this element. Neither tab panel (Overview's nine-card 264 // renderer, profile-overview.js; Achievements) renders an avatar of its 265 // own, so this is the only element it currently has to cover -- see the 266 // header comment on the wireAvatars(root) call in render(), below.
267 function headerAvatarHtml(p) { 268 var name = String((p && p.displayName) || '').trim(); 269 var initial = esc(name ? name.charAt(0).toUpperCase() : '?'); 270 var avatarUrl = p ? safeImageUrl(p.avatarUrl) : ''; 271 if (avatarUrl) { 272 return ( 273 '<div class="tdtt-avatar xl pf-header__avatar">' + 274 '<img class="lb-avatar-img" src="' + esc(avatarUrl) + '" alt="">' + 275 '<span class="lb-avatar-init" hidden>' + initial + '</span>' + 276 '</div>' 277 ); 278 } 279 return '<div class="tdtt-avatar xl pf-header__avatar">' + initial + '</div>'; 280 } 281 282 // The accent MUST land as a CSS custom property (--ac), resolved by the 283 // browser, and NEVER be handed to bannerFx -- passing a raw var(--game-*) 284 // string into canvas colour-parsing code is the Phase 1b Critical 285 // (rgba(NaN,NaN,NaN,...) -> a synchronous throw under prefers-reduced-motion 286 // that blanked the whole app). window.TDTT.bannerFx.refresh() (called from 287 // render(), below) reads --ac back out itself via getComputedStyle, which 288 // is what resolves the var() chain -- see codex.js:533/2781 and 289 // community.js:194/1200 for the same shape on other banners. 290 // Profile Convergence plan, Task 9 -- "#{rank} · {Game} Main", the same 291 // identity line desktop's ProfileHeader (Task 2) renders beside the name. 292 // rank comes from p.ranks[bannerGame] -- ranks is null when the rank query 293 // failed and {} when the player has no rating rows; EITHER missing case 294 // must fall through to "no rank", never "#0" or "#undefined" (NULL MEANS 295 // UNKNOWN, this file's Global Constraints). The game half is gated on that 296 // SAME bannerGame actually having games played -- otherwise a player with 297 // zero games everywhere (bannerGame defaults to Dice Throne with nothing 298 // behind it) would read "Dice Throne Main", a claim the data never made. 299 // Either half can drop out independently; an empty result renders nothing. 300 function headerIdentityLineHtml(p, bannerGame, gameLabel) { 301 var ranks = p.ranks || {}; 302 var rank = typeof ranks[bannerGame] === 'number' ? ranks[bannerGame] : null; 303 var byGameTotals = (p.byGame && p.byGame[bannerGame]) || null; 304 var hasMainGames = !!(byGameTotals && byGameTotals.gamesPlayed > 0); 305 306 var parts = []; 307 if (rank !== null) parts.push('<span class="pf-header__rank">#' + esc(rank) + '</span>'); 308 if (gameLabel && hasMainGames) parts.push('<span>' + esc(gameLabel) + ' Main</span>'); 309 if (!parts.length) return ''; 310 return '<p class="pf-header__identity">' + parts.join('<span class="pf-header__identity-sep"> · </span>') + '</p>'; 311 } 312 313 function headerHtml(p) { 314 var bannerGame = window.TDTT.profileCore.deriveBannerGame(p.bannerKey, p.byGame); 315 var gameInfo = (window.TDTT.game && window.TDTT.game.TABLE && window.TDTT.game.TABLE[bannerGame]) || {}; 316 var accentVar = gameInfo.brandVar || 'var(--cyan)'; 317 var logoSrc = gameInfo.logoFile ? '/m/logos/' + gameInfo.logoFile : ''; 318 var gameLabel = gameInfo.label || ''; 319 320 var memberSinceLabel = formatMemberSince(p.memberSince); 321 var counts = p.counts; 322 323 var metaParts = []; 324 if (p.location) metaParts.push(esc(p.location)); 325 if (memberSinceLabel) metaParts.push(esc(memberSinceLabel)); 326 if (counts && typeof counts.friends === 'number') { 327 metaParts.push(esc(counts.friends) + ' ' + (counts.friends === 1 ? 'Friend' : 'Friends')); 328 } 329 330 return ( 331 '<div class="tdtt-banner pf-header" data-fx="bold" data-variant="library" style="--ac: ' + accentVar + '">' + 332 '<canvas class="tdtt-banner__fx"></canvas>' + 333 (logoSrc 334 ? '<img class="pf-header__logo" src="' + esc(logoSrc) + '" alt="' + esc(gameLabel) + '" aria-hidden="true">' 335 : '') + 336 '<div class="pf-header__row">' + 337 headerAvatarHtml(p) + 338 '<div class="pf-header__id">' + 339 '<h1 class="pf-header__name" title="' + esc(p.displayName) + '">' + esc(p.displayName) + '</h1>' + 340 headerIdentityLineHtml(p, bannerGame, gameLabel) + 341 (p.tagline 342 ? '<p class="pf-header__tagline" title="' + esc(p.tagline) + '">' + esc(p.tagline) + '</p>' 343 : '') + 344 (metaParts.length ? '<p class="pf-header__meta">' + metaParts.join(' · ') + '</p>' : '') + 345 '</div>' + 346 '</div>' + 347 '</div>' 348 ); 349 } 350 351 // Copy link sits under the header, not in it: the header markup carries no controls (pinned by 352 // app-profile-header.test.ts). 353 function copyLinkRowHtml() { 354 return '<div class="pf-header__actions"><button type="button" class="tdtt-btn tdtt-btn--ghost tdtt-btn--sm" id="pf-copy-link">Copy link</button></div>'; 355 } 356
357 // ââ Stat row (Profile Convergence plan, Task 9) â the five value/label 358 // tiles under the header (.tdtt-statrow/.tdtt-stat, chrome.css â the same 359 // primitive home.js's platform strip and desktop's StatTile both use) plus 360 // the Online / In Person surface strip below it. Mirrors desktop's 361 // ProfileStatRow (apps/desktop/src/components/profile/ProfileStatRow.tsx, 362 // Task 3 of this plan) tile-for-tile: same five labels in the same order 363 // (Games, Wins, Losses, Win Rate, then window.TDTT.game.RATING_LABEL -- 364 // NEVER the literal string "ELO", so the label tracks that constant if it 365 // ever changes) and the same "Online"/"In Person" strip wording. Another 366 // agent is building the desktop twin from this identical list -- a wording 367 // change here silently desyncs the two surfaces. 368 // 369 // p.stats/p.surfaces are ALREADY summed across every game and every 370 // surface (buildPublicProfile's combined-surface roll-up, same as GET 371 // /api/stats/:playerId) -- this is the player's career-total strip, not a 372 // per-game one. Rating has no cross-game equivalent (two games' ELO 373 // numbers are not addable), so that one tile reads the player's banner 374 // game specifically, via the same deriveBannerGame() call headerHtml() 375 // already makes for the logo/accent. 376 function numOrDash(v) { 377 return v === null || v === undefined ? 'â' : v; 378 } 379 380 function pctOrDash(v) { 381 return v === null || v === undefined ? 'â' : v + '%'; 382 } 383 384 // Same defensive wrapper hero-detail.js/rules.js already use around 385 // window.bfIcon (/m/bf-icons.js) -- an unknown icon key renders nothing, 386 // never throws. 387 function bfIconHtml(name, size) { 388 return typeof window.bfIcon === 'function' ? window.bfIcon(name, size) : ''; 389 } 390 391 function statRowTileHtml(icon, value, label, accent) { 392 return ( 393 '<div class="tdtt-stat" style="--sec-accent:' + accent + '">' + 394 '<span class="i">' + icon + '</span>' + 395 '<span class="v">' + esc(value) + '</span>' + 396 '<span class="l">' + esc(label) + '</span>' + 397 '<span class="u"></span>' + 398 '</div>' 399 ); 400 } 401 402 // Same "{n} games · {w}W" / em-dash-for-no-games shape as 403 // profile-render.js's surfaceSplitCellHtml â a real zero game count is 404 // "none", not an unknown, so it renders the dash too, not "0 games · 0W". 405 function surfaceStripCellHtml(label, totals) { 406 var t = totals || { gamesPlayed: 0, wins: 0, losses: 0 }; 407 var text = 408 t.gamesPlayed > 0 409 ? t.gamesPlayed + (t.gamesPlayed === 1 ? ' game' : ' games') + ' · ' + numOrDash(t.wins) + 'W' 410 : 'â'; 411 return ( 412 '<div class="pf-surfacestrip__cell"><span class="l">' + esc(label) + '</span>' + 413 '<span class="v">' + esc(text) + '</span></div>' 414 ); 415 } 416 417 function statRowHtml(p) { 418 var stats = p.stats || null; 419 var bannerGame = window.TDTT.profileCore.deriveBannerGame(p.bannerKey, p.byGame); 420 var ratingsSource = p.ratings || p.skillRatings || {}; 421 var rating = typeof ratingsSource[bannerGame] === 'number' ? ratingsSource[bannerGame] : null; 422 var ratingLabel = (window.TDTT.game && window.TDTT.game.RATING_LABEL) || 'Rating'; 423 var winRatePercent = window.TDTT.profileCore.winRatePercent; 424 var wr = stats ? winRatePercent(stats.winRate, stats.gamesPlayed) : null; 425 426 var tilesHtml = 427 statRowTileHtml(bfIconHtml('dice', 18), numOrDash(stats ? stats.gamesPlayed : null), 'Games', 'var(--cyan)') + 428 statRowTileHtml(bfIconHtml('trophy', 18), numOrDash(stats ? stats.wins : null), 'Wins', 'var(--green)') + 429 statRowTileHtml(bfIconHtml('sword', 18), numOrDash(stats ? stats.losses : null), 'Losses', 'var(--magenta)') + 430 statRowTileHtml(bfIconHtml('chart', 18), pctOrDash(wr), 'Win Rate', 'var(--gold)') + 431 statRowTileHtml(bfIconHtml('crown', 18), numOrDash(rating), ratingLabel, 'var(--orange)'); 432 433 var surfaces = p.surfaces || null; 434 var stripHtml = ''; 435 if (surfaces) { 436 var throneRoom = surfaces.throne_room || { gamesPlayed: 0, wins: 0, losses: 0 }; 437 var casual = surfaces.casual || { gamesPlayed: 0, wins: 0, losses: 0 }; 438 var inPerson = { 439 gamesPlayed: (throneRoom.gamesPlayed || 0) + (casual.gamesPlayed || 0), 440 wins: (throneRoom.wins || 0) + (casual.wins || 0), 441 losses: (throneRoom.losses || 0) + (casual.losses || 0), 442 };
443 stripHtml = 444 '<div class="pf-surfacestrip">' + 445 surfaceStripCellHtml('Online', surfaces.online) + 446 surfaceStripCellHtml('In Person', inPerson) + 447 '</div>'; 448 } 449 450 return '<div class="tdtt-statrow">' + tilesHtml + '</div>' + stripHtml; 451 } 452 453 // Phase 4b Task 1 -- profileTabsFor(isOwnProfile()) (window.TDTT. 454 // profileCore, resolved LAZILY at call time per the ruling in this file's 455 // header comment -- capturing it at parse time is the exact Phase 1c 456 // defect Phase 2c fixed) filtered down to IMPLEMENTED_TAB_IDS. Do not 457 // reimplement the ownerOnly rule here; this only ever subtracts ids on top 458 // of what profileTabsFor() already returns. 459 function visibleTabs() { 460 var core = (window.TDTT && window.TDTT.profileCore) || {}; 461 var profileTabsFor = core.profileTabsFor; 462 if (typeof profileTabsFor !== 'function') return []; 463 var all = profileTabsFor(isOwnProfile()); 464 var out = []; 465 for (var i = 0; i < all.length; i++) { 466 if (isImplementedTab(all[i].id)) out.push(all[i]); 467 } 468 return out; 469 } 470 471 // ââ Tab strip â the shared .tdtt-tabs / .tdtt-tabs__tab primitive 472 // (chrome.css:617), the same one hero-detail.js uses. A tab is not a chip 473 // and not a seg-slab; do not restyle it here. ââââââââââââââââââââââââââ 474 function tabStripHtml() { 475 return ( 476 '<div class="tdtt-tabs" role="tablist">' + 477 visibleTabs().map(function (t) { 478 var on = t.id === S.activeTab; 479 return ( 480 '<button type="button" class="tdtt-tabs__tab' + (on ? ' on' : '') + '" role="tab" ' + 481 'aria-selected="' + (on ? 'true' : 'false') + '" data-profile-tab="' + esc(t.id) + '">' + 482 esc(t.label) + '</button>' 483 ); 484 }).join('') + 485 '</div>' 486 ); 487 } 488 489 // ââ Overview tab (Phase 2c, Task 2) â the nine-card Overview, rendered 490 // through window.TDTT.profileOverview (public/shared/profile-overview.js) 491 // rather than profile-render.js's contentHtml(). contentHtml() is a 492 // SEPARATE renderer that stays exactly what it was: the compact 493 // leaderboard player drill-down peek (leaderboard.js:877). Do not merge 494 // the two â see profile-overview.js's header comment. ââââââââââââââââââââ 495 function overviewPanelHtml() { 496 return '<div class="pf-panel">' + window.TDTT.profileOverview.html(S.profile) + '</div>'; 497 } 498 499 // ââ Achievements tab âââââââââââââââââââââââââââââââââââââââââââââââââ 500 // Fetched lazily (only once the tab is first opened â see onTabClick()) 501 // from the two endpoints the old rail card used: 502 // GET /api/achievements/definitions â the full catalog (53 entries) 503 // GET /api/achievements/:playerId â this player's progress rows 504 // Both requireAuth. Fetched together via Promise.all so the "N of M" 505 // header and the list below it are always built from the same snapshot. 506 507 // Phase 5b Task 3 -- rebuilt on the shared taxonomy (window.TDTT. 508 // profileCore, packages/shared/src/profile/achievements.ts) to match 509 // desktop's Achievements.tsx (Phase 5b Task 2) part for part: four summary 510 // tiles, a category chip row (this surface's equivalent of desktop's 511 // sidebar -- see file header comment), an overall progress bar, a Featured 512 // row built from the FULL unfiltered views, and a searchable/sortable 513 // grid. None of the six shared functions below are reimplemented here. 514 515 function achvTilesHtml(summary) { 516 return ( 517 '<div class="pf-achv-tiles">' + 518 '<div class="lb-profile-stat" data-ach-tile="total"><span class="v">' + esc(summary.total) + 519 '</span><span class="l">Total</span></div>' + 520 '<div class="lb-profile-stat" data-ach-tile="unlocked"><span class="v">' + esc(summary.unlocked) + 521 '</span><span class="l">Earned</span></div>' + 522 '<div class="lb-profile-stat" data-ach-tile="locked"><span class="v">' + esc(summary.locked) + 523 '</span><span class="l">Remaining</span></div>' + 524 '<div class="lb-profile-stat" data-ach-tile="completion"><span class="v">' + esc(summary.completionPct) + 525 '%</span><span class="l">Completion</span></div>' + 526 '</div>' 527 ); 528 } 529 530 function achvProgressBarHtml(summary) { 531 return ( 532 '<div class="pf-achv-progress"><span class="pf-achv-progress__bar" style="width:' + 533 esc(summary.completionPct) + '%"></span></div>' 534 ); 535 } 536 537 function achvFeaturedItemHtml(view) { 538 return ( 539 '<div class="pf-achv-featured-item' + (view.completed ? ' is-earned' : '') + '" data-ach-key="' + 540 esc(view.key) + '">' + 541 '<span class="pf-achv-featured-item__icon">' + window.bfIcon(view.icon, 20) + '</span>' + 542 '<span class="pf-achv-featured-item__name">' + esc(view.name) + '</span>' + 543 '</div>' 544 ); 545 } 546 547 // featured MUST be built from the full unfiltered views by the caller 548 // (achievementsCatalogHtml()) -- same rule desktop's Achievements.tsx 549 // follows: this is a cross-category showcase, not a view of the current 550 // grid, so it stays stable while the category filter changes. 551 function achvFeaturedHtml(featured) { 552 if (!featured.length) return ''; 553 var items = ''; 554 for (var i = 0; i < featured.length; i++) items += achvFeaturedItemHtml(featured[i]); 555 return ( 556 '<div class="pf-achv-featured">' + 557 '<div class="lb-profile-section-label">Featured</div>' + 558 '<div class="pf-achv-featured-row" data-ach-featured-row>' + items + '</div>' + 559 '</div>' 560 ); 561 } 562 563 // The category selector for this surface's narrower layout -- a 564 // horizontal chip row (.chip/.chip-row, controls.css), same primitive and 565 // same on/off selected-state pattern as this file's own .pf-gs game 566 // switcher (see wireGameSwitcher()'s header comment), not a literal 567 // sidebar. All seven ACHIEVEMENT_CATEGORIES render even at a zero count. 568 function achvCategoryChipsHtml(summary, grouped, activeCategory) { 569 var core = (window.TDTT && window.TDTT.profileCore) || {}; 570 var categories = core.ACHIEVEMENT_CATEGORIES || []; 571 var labels = core.ACHIEVEMENT_CATEGORY_LABELS || {}; 572 var html = 573 '<button type="button" class="chip' + (!activeCategory ? ' on' : '') + '" data-ach-category="">' + 574 'All (' + esc(summary.total) + ')</button>'; 575 for (var i = 0; i < categories.length; i++) { 576 var cat = categories[i]; 577 var count = (grouped[cat] || []).length; 578 var on = activeCategory === cat; 579 html += 580 '<button type="button" class="chip' + (on ? ' on' : '') + '" data-ach-category="' + esc(cat) + '" ' + 581 'data-testid="ach-category-' + esc(cat) + '">' + 582 '<span data-ach-category-label="' + esc(cat) + '">' + esc(labels[cat] || cat) + ' (' + esc(count) + ')</span>' + 583 '</button>'; 584 } 585 return '<div class="chip-row pf-achv-categories" role="group" aria-label="Achievement categories">' + html + '</div>'; 586 } 587 588 var ACHV_SORT_OPTIONS = [ 589 { value: 'progress', label: 'Progress' }, 590 { value: 'name', label: 'Name' },
591 { value: 'recent', label: 'Recent' }, 592 ]; 593 594 function achvSearchSortHtml(query, sort) { 595 var sortHtml = ''; 596 for (var i = 0; i < ACHV_SORT_OPTIONS.length; i++) { 597 var opt = ACHV_SORT_OPTIONS[i]; 598 var on = opt.value === sort; 599 sortHtml += 600 '<button type="button" class="chip' + (on ? ' on' : '') + '" data-ach-sort="' + esc(opt.value) + '">' + 601 esc(opt.label) + '</button>'; 602 } 603 return ( 604 '<div class="pf-achv-controls">' + 605 '<input type="text" class="pf-achv-search" data-ach-search value="' + esc(query) + 606 '" placeholder="Search achievementsâ¦" aria-label="Search achievements">' + 607 '<div class="chip-row pf-achv-sort" role="group" aria-label="Sort achievements">' + sortHtml + '</div>' + 608 '</div>' 609 ); 610 } 611 612 function achvCardHtml(view) { 613 var statusHtml = view.completed 614 ? '<span class="pf-achv-card__status is-earned">Unlocked</span>' 615 : '<span class="pf-achv-card__status">Locked</span>'; 616 var bodyExtra; 617 if (view.completed) { 618 bodyExtra = view.completedAt 619 ? '<span class="pf-achv-card__completed">Completed ' + 620 esc(new Date(view.completedAt).toLocaleDateString()) + '</span>' 621 : ''; 622 } else { 623 bodyExtra = 624 '<div class="pf-achv-card__progress-row"><span>' + esc(view.progress) + ' / ' + esc(view.requirement) + 625 '</span><span>' + esc(view.pct) + '%</span></div>' + 626 '<div class="pf-achv-card__progress"><span class="pf-achv-card__progress-bar" style="width:' + 627 esc(view.pct) + '%"></span></div>'; 628 } 629 return ( 630 '<div class="pf-achv-card' + (view.completed ? ' is-earned' : '') + '" data-ach-key="' + esc(view.key) + '">' + 631 '<span class="pf-achv-card__icon">' + window.bfIcon(view.icon, 22) + '</span>' + 632 '<div class="pf-achv-card__body">' + 633 '<div class="pf-achv-card__head"><span class="pf-achv-card__name">' + esc(view.name) + '</span>' + 634 statusHtml + '</div>' + 635 '<div class="pf-achv-card__desc">' + esc(view.description) + '</div>' + 636 bodyExtra + 637 '</div>' + 638 '</div>' 639 ); 640 } 641 642 function achvGridHtml(visible) { 643 if (!visible.length) { 644 return '<div class="pf-achv-empty" data-ach-grid><p class="lb-empty-sub">No achievements match.</p></div>'; 645 } 646 var html = ''; 647 for (var i = 0; i < visible.length; i++) html += achvCardHtml(visible[i]); 648 return '<div class="pf-achv-grid" data-ach-grid>' + html + '</div>'; 649 } 650 651 function achievementsCatalogHtml() { 652 var core = (window.TDTT && window.TDTT.profileCore) || {}; 653 var buildViews = core.buildAchievementViews; 654 var groupFn = core.groupByCategory; 655 var summaryFn = core.achievementSummary; 656 var featuredFn = core.featuredFor; 657 var searchFn = core.searchAchievements; 658 var sortFn = core.sortAchievements; 659 if ( 660 typeof buildViews !== 'function' || typeof groupFn !== 'function' || 661 typeof summaryFn !== 'function' || typeof featuredFn !== 'function' || 662 typeof searchFn !== 'function' || typeof sortFn !== 'function' 663 ) { 664 return '<div class="lb-status lb-error"><p class="lb-error-title">Achievements are unavailable</p></div>'; 665 } 666 667 var views = buildViews(S.achv.defs || [], S.achv.rows || []); 668 var summary = summaryFn(views); 669 var grouped = groupFn(views); 670 var featured = featuredFn(views); 671 672 var activeCategory = S.achvUi.category; 673 var categoryFiltered = activeCategory ? grouped[activeCategory] || [] : views; 674 var searched = searchFn(categoryFiltered, S.achvUi.query); 675 var visible = sortFn(searched, S.achvUi.sort); 676 677 return ( 678 '<div class="pf-achv">' + 679 achvTilesHtml(summary) + 680 achvProgressBarHtml(summary) + 681 achvFeaturedHtml(featured) + 682 achvCategoryChipsHtml(summary, grouped, activeCategory) + 683 achvSearchSortHtml(S.achvUi.query, S.achvUi.sort) + 684 achvGridHtml(visible) + 685 '</div>' 686 ); 687 } 688 689 // Signed-out / loading / error are all rendered honestly â never a 690 // fabricated "0 of 0 earned" (that ruling has been made twice in this 691 // project). The old rail card could just omit itself entirely on any of 692 // these;
692 a tab strip can't disappear the way a card could, so each state 693 // gets real, distinct copy instead. 694 function achievementsPanelHtml() { 695 if (!playerId()) { 696 return ( 697 '<div class="lb-status lb-empty"><p class="lb-empty-title">Sign in to see your achievements</p>' + 698 '<p class="lb-empty-sub">Your progress will appear here once youâre signed in.</p></div>' 699 ); 700 } 701 if (S.achv.status === 'loading' || S.achv.status === 'idle') { 702 return '<div class="lb-status lb-loading">Loading achievementsâ¦</div>'; 703 } 704 if (S.achv.status === 'error') { 705 return ( 706 '<div class="lb-status lb-error"><p class="lb-error-title">Couldnât load achievements</p>' + 707 '<p class="lb-error-detail">' + esc(S.achv.error) + '</p>' + 708 '<button type="button" class="lb-btn" id="pf-achv-retry">Retry</button></div>' 709 ); 710 } 711 return achievementsCatalogHtml(); 712 } 713 714 // ââ Game Stats / Heroes / Match History (Phase 4b Tasks 1-3) ââââââââââââ 715 // The idle/loading/error states below are Task 1's -- the same honest 716 // states achievementsPanelHtml() above renders (never a fabricated count/ 717 // list for a failed or not-yet-fetched request). The 'ready' branch below 718 // delegates each tab to its real body in window.TDTT.profilePanels 719 // (public/shared/profile-panels.js): gameStatsHtml (Task 2), heroesHtml 720 // and matchHistoryHtml (Task 3). 721 function lazyTabPanelHtml(tabId) { 722 var label = tabLabel(tabId); 723 if (!playerId()) { 724 return ( 725 '<div class="lb-status lb-empty"><p class="lb-empty-title">Sign in to see your ' + 726 esc(label.toLowerCase()) + '</p>' + 727 '<p class="lb-empty-sub">This will appear here once youâre signed in.</p></div>' 728 ); 729 } 730 var state = S.tabData[tabId] || emptyTabState(); 731 if (state.status === 'loading' || state.status === 'idle') { 732 return '<div class="lb-status lb-loading">Loading ' + esc(label.toLowerCase()) + 'â¦</div>'; 733 } 734 if (state.status === 'error') { 735 return ( 736 '<div class="lb-status lb-error"><p class="lb-error-title">Couldnât load ' + esc(label) + '</p>' + 737 '<p class="lb-error-detail">' + esc(state.error) + '</p>' + 738 '<button type="button" class="lb-btn" data-profile-tab-retry="' + esc(tabId) + '">Retry</button></div>' 739 ); 740 } 741 // Phase 4b Task 2/3 -- all three lazy data tabs have real bodies now, 742 // via window.TDTT.profilePanels (public/shared/profile-panels.js). 743 if (tabId === 'game-stats') { 744 // Phase 4b follow-up -- state.data is now { breakdowns, counters } 745 // (see loadTabData()'s own header comment below), not a bare array. 746 // gameStatsHtml()'s counters param is its FOURTH, trailing argument on 747 // purpose, appended after selectedGame -- see that function's own 748 // header comment in profile-panels.js. 749 var gsData = state.data || {}; 750 return ( 751 '<div class="pf-panel">' + 752 window.TDTT.profilePanels.gameStatsHtml(S.profile, gsData.breakdowns, S.gameStatsGame, gsData.counters) + 753 '</div>' 754 ); 755 } 756 if (tabId === 'heroes') { 757 // Phase 6 Task 3 -- state.data is now { heroes, roster } 758 // (loadTabData()'s own header comment below), not a bare array. 759 // archetypeBySlugFromRoster() degrades a missing/failed roster to an 760 // empty map -- see heroesHtml()'s own header comment in 761 // profile-panels.js for what that does to the filter/distribution. 762 // matchHistory is read OPPORTUNISTICALLY from S.tabData['match-history'] 763 // -- Heroes and Match History are separate lazy tabs, so Heroes can be 764 // opened before Match History has ever been fetched. 765 // 766 // Fix round 1 (Important finding): this passes null -- NOT [] -- 767 // until Match History's own fetch has actually reached 'ready'. null 768 // and [] are different claims to heroesHtml()/heroTrendHtml(): null 769 // means "haven't looked", [] means "looked, found nothing decided". 770 // Collapsing both to [] made the trend render a false "no games" 771 // message on a first-ever Heroes visit (before Match History was 772 // opened) instead of an honest "not loaded yet" one. Never trigger a 773 // fetch here -- that would race Match History's own tabDataSeq 774 // generation guard from a second loader; see loadTabData()'s own 775 // comment on why this stays opportunistic-only. 776 var heroesData = state.data || {}; 777 var heroesList = Array.isArray(heroesData.heroes) ? heroesData.heroes : []; 778 var archetypeMap = archetypeBySlugFromRoster(heroesData.roster); 779 var historyState = S.tabData['match-history']; 780 var historyList = 781 historyState && historyState.status === 'ready' && Array.isArray(historyState.data) ? historyState.data : null; 782 return ( 783 '<div class="pf-panel">' + 784 window.TDTT.profilePanels.heroesHtml(heroesList, archetypeMap, historyList, S.heroesUi) + 785 '</div>' 786 ); 787 } 788 if (tabId === 'match-history') { 789 return '<div class="pf-panel">' + window.TDTT.profilePanels.matchHistoryHtml(state.data) + '</div>'; 790 } 791 // Unreachable given isLazyDataTab()'s three ids above, kept as an honest 792 // fallback rather than a silent blank panel. 793 return '<div class="pf-panel"><p class="lb-empty-sub">' + esc(label) + ' is coming soon.</p></div>'; 794 } 795 796 // Phase B1 Task 6 -- the container is all this file renders for 'decks'; 797 // window.TDTT.profileDecks.mount() (wired from render(), below) owns 798 // everything inside it. 799 function decksPanelHtml() { 800 return '<div class="pf-panel" id="pf-decks-root"></div>'; 801 } 802 803 function panelHtml() { 804 if (S.activeTab === 'achievements') return achievementsPanelHtml(); 805 if (isLazyDataTab(S.activeTab)) return lazyTabPanelHtml(S.activeTab); 806 if (S.activeTab === 'decks') return decksPanelHtml(); 807 return overviewPanelHtml(); 808 } 809 810 // GET endpoint for each lazy data tab, scoped to the VIEWED player (id is 811 // always viewedPlayerId(), never playerId() directly -- same reasoning as 812 // loadAchievements()'s own id parameter, fix round 1: the shown data must 813 // match the header/Overview, not the signed-in viewer's own). These three 814 // already accept an arbitrary player id (apps/server/src/routes/stats.ts). 815 // Same-named limit as profile-panels.js's own MATCH_HISTORY_LIMIT 816 // (window.TDTT.profilePanels, loaded before this file -- see index.html's 817 // script order) -- one number, read from one place, instead of a second 818 // hardcoded literal 50 here. Inlined (not a separate top-level function)
819 // so tabDataEndpoint stays the single self-contained unit a test sandbox 820 // can extract on its own. The "|| 50" fallback only matters in a test 821 // sandbox that extracts this function without also loading 822 // profile-panels.js; in the real app the assignment above has already run 823 // by the time this file executes. 824 function tabDataEndpoint(tabId, id) { 825 if (tabId === 'game-stats') return '/api/stats/' + encodeURIComponent(id) + '/games'; 826 if (tabId === 'heroes') return '/api/stats/' + encodeURIComponent(id) + '/heroes'; 827 if (tabId === 'match-history') { 828 var panels = window.TDTT && window.TDTT.profilePanels; 829 var limit = (panels && panels.MATCH_HISTORY_LIMIT) || 50; 830 return '/api/stats/' + encodeURIComponent(id) + '/history?limit=' + limit; 831 } 832 return null; 833 } 834 835 // Per-tab sequence counters, same invalidation shape as seq/achvSeq above 836 // (an in-flight request from a since-abandoned view must never clobber 837 // state a later, faster request already wrote). 838 var tabDataSeq = { 'game-stats': 0, heroes: 0, 'match-history': 0 }; 839 840 function loadTabData(tabId) { 841 var id = viewedPlayerId(); 842 if (!id) { 843 S.tabData[tabId] = emptyTabState(); 844 return render(); 845 } 846 var endpoint = tabDataEndpoint(tabId, id); 847 if (!endpoint) return; 848 tabDataSeq[tabId] = (tabDataSeq[tabId] || 0) + 1; 849 var mine = tabDataSeq[tabId]; 850 S.tabData[tabId] = { status: 'loading', data: null, error: null }; 851 render(); 852 853 // authFetch + parse for a single endpoint -- a local helper (not a 854 // separate top-level function) so loadTabData stays one self-contained 855 // unit a test sandbox can extract on its own. 856 function fetchJson(url) { 857 return window.BFAuth.authFetch(url, { cache: 'no-store' }).then(function (r) { 858 if (!r.ok) throw new Error(tabId + ' request failed: ' + r.status); 859 return r.json(); 860 }); 861 } 862 863 // Phase 4b follow-up -- the Game Stats tab additionally needs the flat 864 // counter map (GET /api/stats/:id) so its Dice Throne branch can render 865 // desktop's damage/healing/ultimate tiles (gameStatsHtml()'s counters 866 // param, profile-panels.js). Fetched alongside the existing /games 867 // breakdown call in ONE Promise.all so both requests share the same 868 // loading/ready/error transition and the same tabDataSeq generation -- 869 // if EITHER leg fails, the whole tab reports 'error' (dashes via the 870 // error screen, never a partially-populated stat wall of zeros). Every 871 // other lazy tab is untouched and keeps its original single-fetch shape. 872 // Phase 6 Task 3 -- the Heroes tab additionally needs the archetype 873 // roster (window.TDTT.codex.roster(), GET /api/encyclopedia/characters) 874 // to build the slug -> archetype map heroesHtml() needs for its filter 875 // and distribution chart -- see archetypeBySlugFromRoster() above and 876 // heroesHtml()'s own header comment (profile-panels.js) on graceful 877 // degradation. Fetched ALONGSIDE the existing /heroes stats call in ONE 878 // Promise.all, but UNLIKE the Game Stats pair above, a roster failure 879 // (or window.TDTT.codex not being loaded at all) must NOT fail the whole 880 // Heroes tab -- archetype data is a nice-to-have, the hero stats 881 // themselves are not, so the roster leg is caught internally and 882 // resolves to null rather than propagating a rejection into the outer 883 // Promise.all. 884 function fetchRoster() { 885 var codex = window.TDTT && window.TDTT.codex; 886 if (!codex || typeof codex.roster !== 'function') return Promise.resolve(null); 887 return codex.roster().catch(function () { 888 return null; 889 }); 890 } 891 892 var request = 893 tabId === 'game-stats' 894 ? Promise.all([fetchJson(endpoint), fetchJson('/api/stats/' + encodeURIComponent(id))]).then(function ( 895 results, 896 ) { 897 return { breakdowns: Array.isArray(results[0]) ? results[0] : [], counters: results[1] || null }; 898 }) 899 : tabId === 'heroes' 900 ? Promise.all([fetchJson(endpoint), fetchRoster()]).then(function (results) { 901 return { heroes: Array.isArray(results[0]) ? results[0] : [], roster: results[1] || null }; 902 }) 903 : fetchJson(endpoint); 904 905 request 906 .then(function (data) { 907 if (mine !== tabDataSeq[tabId]) return; 908 S.tabData[tabId] = { status: 'ready', data: data, error: null }; 909 render(); 910 }) 911 .catch(function (e) { 912 if (mine !== tabDataSeq[tabId]) return; 913 S.tabData[tabId] = { status: 'error', data: null, error: (e && e.message) || 'Failed to load' }; 914 render(); 915 }); 916 } 917 918 // Fires the FIRST fetch for whichever tab is becoming active -- called 919 // both from onTabClick() (a click) and from load()'s success handler (a 920 // deep link straight to e.g. ?tab=heroes, where nothing was ever clicked). 921 // Returns true when it started a fetch (loadAchievements()/loadTabData() 922 // already call render() themselves in that case); false means the caller 923 // still needs to render() -- either this tab has no data of its own 924 // (Overview) or it was already loaded/loading/erred before this call. 925 function ensureTabLoaded(tabId) { 926 if (tabId === 'achievements') { 927 if (S.achv.status !== 'idle') return false;
928 loadAchievements(); 929 return true; 930 } 931 if (isLazyDataTab(tabId)) { 932 var state = S.tabData[tabId]; 933 if (state && state.status !== 'idle') return false; 934 loadTabData(tabId); 935 return true; 936 } 937 return false; 938 } 939 940 // Selecting a tab updates ?tab= and preserves ?player= -- same convention 941 // Phase 3 established for community.js's showDetail()/showBrowse() 942 // (push, then router.go(section, search)). Reads the RAW ?player= value 943 // (not viewedPlayerId(), which already defaults to the signed-in id) so a 944 // signed-out visit with no ?player= stays with no ?player= after a click. 945 function tabSearch(tabId) { 946 var pid = null; 947 try { 948 pid = new URLSearchParams(window.location.search).get('player'); 949 } catch (e) { 950 pid = null; 951 } 952 var search = '?tab=' + encodeURIComponent(tabId); 953 if (pid) search += '&player=' + encodeURIComponent(pid); 954 return search; 955 } 956 957 // Resolves a raw ?tab= value (readTabParam()) to a tab id that will 958 // actually render. Layered the same way visibleTabs() is: ask the shared 959 // helper first (it owns the ownerOnly rule and the "not a real tab id at
960 // all" case), THEN re-check the result against IMPLEMENTED_TAB_IDS -- an 961 // owner's ?tab=collection must fall back to Overview too, even though 962 // resolveProfileTab() alone would call it valid for an owner. 963 function resolveActiveTab(raw) { 964 var core = (window.TDTT && window.TDTT.profileCore) || {}; 965 var defaultTab = core.DEFAULT_PROFILE_TAB || 'overview'; 966 if (typeof core.resolveProfileTab !== 'function') return defaultTab; 967 var resolved = core.resolveProfileTab(raw, isOwnProfile()); 968 return isImplementedTab(resolved) ? resolved : defaultTab; 969 } 970 971 function loadAchievements() { 972 // The achievements shown are the VIEWED player's, not necessarily the 973 // signed-in one -- see viewedPlayerId()'s header comment. 974 var id = viewedPlayerId(); 975 if (!id) { 976 S.achv = { status: 'idle', defs: null, rows: null, error: null }; 977 return render(); 978 } 979 var mine = ++achvSeq; 980 S.achv.status = 'loading'; 981 S.achv.error = null; 982 render(); 983 Promise.all([ 984 window.BFAuth.authFetch('/api/achievements/definitions', { cache: 'no-store' }).then(function (r) { 985 if (!r.ok) throw new Error('achievement definitions request failed: ' + r.status); 986 return r.json(); 987 }), 988 window.BFAuth.authFetch('/api/achievements/' + encodeURIComponent(id), { cache: 'no-store' }).then(function (r) { 989 if (!r.ok) throw new Error('achievements request failed: ' + r.status); 990 return r.json(); 991 }), 992 ]) 993 .then(function (results) { 994 if (mine !== achvSeq) return; 995 var defs = Array.isArray(results[0]) ? results[0] : []; 996 var rows = Array.isArray(results[1]) ? results[1] : []; 997 S.achv = { status: 'ready', defs: defs, rows: rows, error: null }; 998 render(); 999 }) 1000 .catch(function (e) { 1001 if (mine !== achvSeq) return; 1002 S.achv = { status: 'error', defs: null, rows: null, error: (e && e.message) || 'Failed to load' }; 1003 render(); 1004 }); 1005 } 1006 1007 // Phase 4b Task 2 -- the Game Stats switcher (Overall chip + per-game 1008 // chips + each Overall row, all carrying data-game-switch) is wired with 1009 // ONE delegated click listener on its container, found via a SINGLE 1010 // root.querySelector() call -- deliberately NOT a second 1011 // root.querySelectorAll() pass. wireTabs() above already owns the one 1012 // querySelectorAll('[data-profile-tab]') call this render(); a second 1013 // querySelectorAll here would re-walk the tab strip too (it lives in the 1014 // same innerHTML) and risk re-registering the wrong handler against those 1015 // same elements. Delegation sidesteps that entirely: one listener, no 1016 // second broad query, and it self-heals across every re-render since 1017 // render() rebuilds root.innerHTML (and this wiring) from scratch anyway. 1018 function onGameSwitchContainerClick(e) { 1019 var target = e.target && typeof e.target.closest === 'function' ? e.target.closest('[data-game-switch]') : null; 1020 if (!target) return; 1021 var g = target.getAttribute('data-game-switch'); 1022 if (!g || g === S.gameStatsGame) return; 1023 S.gameStatsGame = g; 1024 render(); 1025 } 1026 1027 function wireGameSwitcher() { 1028 var container = root.querySelector('.pf-gs'); 1029 if (container) container.addEventListener('click', onGameSwitchContainerClick); 1030 } 1031 1032 // Phase 6 Task 3 -- the Heroes tab's sort chips, archetype filter chips, 1033 // pager and hero cards are ALL wired with ONE delegated click listener on 1034 // the whole .pf-heroes container, same reasoning as 1035 // onGameSwitchContainerClick/onAchvControlsClick above (no second broad 1036 // querySelectorAll pass, self-heals across every render()). 1037 function onHeroesControlsClick(e) { 1038 var target = e.target; 1039 var sortBtn = target && typeof target.closest === 'function' ? target.closest('[data-hero-sort]') : null; 1040 if (sortBtn) { 1041 var sortVal = sortBtn.getAttribute('data-hero-sort'); 1042 if (sortVal && sortVal !== S.heroesUi.sort) { 1043 S.heroesUi.sort = sortVal; 1044 S.heroesUi.page = 1; 1045 render(); 1046 } 1047 return; 1048 } 1049 var archBtn = target && typeof target.closest === 'function' ? target.closest('[data-hero-archetype]') : null; 1050 if (archBtn) { 1051 var archVal = archBtn.getAttribute('data-hero-archetype') || ''; 1052 // Empty value ("All") always clears; re-clicking the already-active 1053 // chip toggles it off -- same pattern onAchvControlsClick's category 1054 // chips already establish on this surface. 1055 var nextArchetype = archVal === '' ? null : S.heroesUi.archetype === archVal ? null : archVal; 1056 if (nextArchetype !== S.heroesUi.archetype) { 1057 S.heroesUi.archetype = nextArchetype; 1058 S.heroesUi.page = 1; 1059 render(); 1060 } 1061 return; 1062 } 1063 var pagerBtn = target && typeof target.closest === 'function' ? target.closest('[data-hero-pager]') : null; 1064 if (pagerBtn) { 1065 if (pagerBtn.disabled) return; 1066 var dir = pagerBtn.getAttribute('data-hero-pager'); 1067 S.heroesUi.page = dir === 'prev' ? Math.max(1, S.heroesUi.page - 1) : S.heroesUi.page + 1; 1068 render(); 1069 return; 1070 } 1071 var cardBtn = target && typeof target.closest === 'function' ? target.closest('[data-hero-card]') : null; 1072 if (cardBtn) { 1073 var slug = cardBtn.getAttribute('data-hero-card'); 1074 if (slug && slug !== S.heroesUi.selectedSlug) { 1075 S.heroesUi.selectedSlug = slug; 1076 render(); 1077 } 1078 } 1079 } 1080
1081 function wireHeroesControls() { 1082 var container = root.querySelector('.pf-heroes'); 1083 if (container) container.addEventListener('click', onHeroesControlsClick); 1084 } 1085 1086 // Phase 5b Task 3 -- the Achievements tab's category chips and sort chips, 1087 // ONE delegated click listener on the whole .pf-achv container (same 1088 // reasoning as onGameSwitchContainerClick above: no second broad 1089 // querySelectorAll pass, self-heals across every full-tree render()). 1090 // Clicking the already-active category chip clears the filter, same 1091 // toggle desktop's Achievements.tsx uses. This only writes S.achvUi 1092 // (UI-only state) and re-renders -- it never touches S.achv or triggers a 1093 // refetch. 1094 function onAchvControlsClick(e) { 1095 var catBtn = e.target && typeof e.target.closest === 'function' ? e.target.closest('[data-ach-category]') : null; 1096 if (catBtn) { 1097 var cat = catBtn.getAttribute('data-ach-category') || ''; 1098 S.achvUi.category = cat === '' ? null : (S.achvUi.category === cat ? null : cat); 1099 render(); 1100 return; 1101 } 1102 var sortBtn = e.target && typeof e.target.closest === 'function' ? e.target.closest('[data-ach-sort]') : null; 1103 if (sortBtn) { 1104 var sortVal = sortBtn.getAttribute('data-ach-sort'); 1105 if (sortVal && sortVal !== S.achvUi.sort) { 1106 S.achvUi.sort = sortVal; 1107 render(); 1108 } 1109 } 1110 } 1111 1112 // The search input is a separate listener (an 'input' event, not a click) 1113 // on the same container's child -- render() rebuilds root.innerHTML on 1114 // every keystroke, which would otherwise drop focus and caret position, so 1115 // this restores both on the freshly rebuilt element after each render(). 1116 function onAchvSearchInput(e) { 1117 var caret = null; 1118 try { 1119 caret = e.target.selectionStart; 1120 } catch (err) { 1121 caret = null; 1122 } 1123 S.achvUi.query = e.target.value; 1124 render(); 1125 var el = root.querySelector('[data-ach-search]'); 1126 if (el) { 1127 el.focus(); 1128 if (caret !== null) { 1129 try { 1130 el.setSelectionRange(caret, caret); 1131 } catch (err2) { 1132 /* some input types don't support setSelectionRange -- harmless */ 1133 } 1134 } 1135 } 1136 } 1137 1138 function wireAchvControls() { 1139 var container = root.querySelector('.pf-achv'); 1140 if (!container) return; 1141 container.addEventListener('click', onAchvControlsClick); 1142 var search = container.querySelector('[data-ach-search]'); 1143 if (search) search.addEventListener('input', onAchvSearchInput); 1144 } 1145 1146 function onTabClick(e) { 1147 var id = e.currentTarget.getAttribute('data-profile-tab'); 1148 if (id === S.activeTab) return; 1149 S.activeTab = id; 1150 if (window.TDTTRouter) window.TDTTRouter.go('profile', tabSearch(id)); 1151 // Lazy fetch â only the FIRST time a lazily-loaded tab is opened. 1152 // ensureTabLoaded() covers both Achievements (S.achv) and the three 1153 // Phase 4b tabs (S.tabData) with the same "idle is the only status this 1154 // ever fires from" rule; a later re-open reuses whatever 'ready'/'error' 1155 // state the first fetch left behind (the retry button covers a failed 1156 // fetch). It renders itself when it starts a fetch; otherwise this call 1157 // still has to paint the newly active tab. 1158 if (!ensureTabLoaded(id)) render(); 1159 } 1160 1161 // Fix round 1 -- router.js's render() early-returns when the resolved 1162 // section has not changed (router.js:176: "already mounted; nothing to 1163 // swap"), so window.TDTTRouter.go('profile', search) above only updates 1164 // the address bar; it does NOT undo a tab switch on Back/Forward. This 1165 // section owns five views (one per tab) now, exactly the shape 1166 // CLAUDE_WEB_DESKTOP.md's rule targets ("a section owning multiple views 1167 // must swap its own view, listen for popstate itself, and remove that 1168 // listener in unmount() by the same function reference") -- same pattern 1169 // as community.js's own onPopState. Re-resolves ?tab= through the same 1170 // resolveActiveTab() onTabClick/mount() use, so a Back navigation can 1171 // never land on an unimplemented or invalid tab any more than a fresh 1172 // visit could. Never pushes -- the browser already moved the history 1173 // pointer; pushing again here would push a NEW entry on top of the one it 1174 // just navigated to. 1175 function onTabPopState() { 1176 if (!root) return; 1177 var id = resolveActiveTab(readTabParam()); 1178 if (id === S.activeTab) return; 1179 S.activeTab = id; 1180 if (!ensureTabLoaded(id)) render(); 1181 } 1182 1183 function wireTabs() { 1184 var tabs = root.querySelectorAll('[data-profile-tab]'); 1185 for (var i = 0; i < tabs.length; i++) { 1186 tabs[i].addEventListener('click', onTabClick); 1187 } 1188 } 1189 1190 // Drop-in for `root.innerHTML = html` that keeps the existing banner node 1191 // (see stable-banner.js); plain assignment if that script is absent. 1192 function setRootHtml(html) { 1193 if (window.TDTT && window.TDTT.stableBanner) window.TDTT.stableBanner.setHtml(root, html); 1194 else root.innerHTML = html; 1195 } 1196 1197 function render() { 1198 if (!root) return; 1199 if (S.loading) { 1200 root.innerHTML = '<div class="lb-status lb-loading">Loadingâ¦</div>'; 1201 return; 1202 } 1203 if (S.error) { 1204 root.innerHTML = 1205 '<div class="lb-status lb-error"><p class="lb-error-title">Couldnât load your profile</p>' + 1206 '<p class="lb-error-detail">' + esc(S.error) + '</p>' + 1207 '<button type="button" class="lb-btn" id="pf-retry">Retry</button></div>'; 1208 var r = document.getElementById('pf-retry'); 1209 if (r) r.addEventListener('click', load); 1210 return; 1211 } 1212 if (!S.profile) { 1213 root.innerHTML = 1214 '<div class="lb-status lb-empty"><p class="lb-empty-title">No profile yet</p>' + 1215 '<p class="lb-empty-sub">Play a game and your stats will appear here.</p></div>'; 1216 return; 1217 } 1218 setRootHtml( 1219 headerHtml(S.profile) + copyLinkRowHtml() + statRowHtml(S.profile) + tabStripHtml() + 1220 '<div class="pf-tabpanel" role="tabpanel">' + panelHtml() + '</div>' 1221 ); 1222 wireTabs(); 1223 // Phase B1 Task 6 -- root.innerHTML above just rebuilt the whole tree, 1224 // so any previous #pf-decks-root is already detached; tear down its 1225 // mount() before doing anything else, then remount fresh if 'decks' is 1226 // still (or newly) the active tab. Unconditional on every render() call, 1227 // not just on a tab change, because render() has no cheaper way to tell 1228 // "the DOM node changed" from "the tab changed" -- both replace 1229 // root.innerHTML the same way. 1230 if (decksMount) { 1231 decksMount.destroy(); 1232 decksMount = null; 1233 } 1234 if (S.activeTab === 'decks' && window.TDTT && window.TDTT.profileDecks) { 1235 var decksRootEl = document.getElementById('pf-decks-root'); 1236 if (decksRootEl) { 1237 decksMount = window.TDTT.profileDecks.mount(decksRootEl, { 1238 playerId: viewedPlayerId(), 1239 isOwnProfile: isOwnProfile(), 1240 }); 1241 } 1242 } 1243 // Covers the header's own avatar -- headerHtml() renders on every call 1244 // regardless of active tab, and neither panel (Overview's nine-card 1245 // renderer; Achievements) has an avatar of its own, so this is the only 1246 // element it wires today. Called unscoped/unconditionally because 1247 // root.innerHTML above wipes and rebuilds the whole tree (header 1248 // included) on every render(), not just when the active tab changes. 1249 window.TDTT.profileRender.wireAvatars(root); 1250 var copyProfileLink = document.getElementById('pf-copy-link'); 1251 if (copyProfileLink) { 1252 copyProfileLink.addEventListener('click', function () { 1253 // Text comes only from TDTT.copyLink (fixed origin + /player/<id>), never the payload. 1254 if (window.TDTT && window.TDTT.copyLink) window.TDTT.copyLink.copy('player', viewedPlayerId()); 1255 }); 1256 } 1257 if (window.TDTT && window.TDTT.bannerFx) window.TDTT.bannerFx.refresh(); 1258 if (S.activeTab === 'achievements' && S.achv.status === 'error') { 1259 var ar = document.getElementById('pf-achv-retry'); 1260 if (ar) ar.addEventListener('click', loadAchievements); 1261 } 1262 if (S.activeTab === 'achievements' && S.achv.status === 'ready') wireAchvControls(); 1263 if (isLazyDataTab(S.activeTab)) { 1264 var tabState = S.tabData[S.activeTab]; 1265 if (tabState && tabState.status === 'error') { 1266 var tabRetry = root.querySelector('[data-profile-tab-retry]'); 1267 if (tabRetry) { 1268 tabRetry.addEventListener('click', function () { 1269 loadTabData(tabRetry.getAttribute('data-profile-tab-retry')); 1270 }); 1271 } 1272 } 1273 } 1274 if (S.activeTab === 'game-stats') { 1275 var gsState = S.tabData['game-stats']; 1276 if (gsState && gsState.status === 'ready') wireGameSwitcher(); 1277 } 1278 if (S.activeTab === 'heroes') { 1279 var heroesState = S.tabData.heroes; 1280 if (heroesState && heroesState.status === 'ready') wireHeroesControl
1280s(); 1281 } 1282 } 1283 1284 function load() { 1285 // The PROFILE fetched is the viewed player's, not necessarily the 1286 // signed-in one -- see viewedPlayerId()'s header comment. 1287 var id = viewedPlayerId(); 1288 if (!id) { 1289 S.error = 'Signed out.'; 1290 S.loading = false; 1291 return render(); 1292 } 1293 var mine = ++seq; 1294 S.loading = true; 1295 S.error = null; 1296 render(); 1297 window.TDTT.lb 1298 .profile(id) 1299 .then(function (p) { 1300 if (mine !== seq) return; 1301 S.profile = p; 1302 S.loading = false; 1303 render(); 1304 // Deep-link support: a visit straight to e.g. ?tab=heroes resolved 1305 // S.activeTab in mount() below, before the profile existed to render 1306 // a tab strip onto -- nothing could have clicked that tab, so its 1307 // own lazy fetch has to start here instead. A no-op for the default 1308 // 'overview' tab (isLazyDataTab/'achievements' both say no), which 1309 // is how "opening Overview issues no fetch beyond the profile one" 1310 // stays true. 1311 ensureTabLoaded(S.activeTab); 1312 }) 1313 .catch(function (e) { 1314 if (mine !== seq) return; 1315 S.loading = false; 1316 // A 401 means the server no longer accepts this token -- expired, 1317 // revoked, banned account, rotated JWT_SECRET, whatever. The token's 1318 // own exp claim can still look unexpired to isTokenExpired(), so a 1319 // bare reload() would land right back on this same mounted section, 1320 // fetch again, get 401 again, and reload again: an infinite loop. 1321 // Dropping the stored session first is what makes isSignedIn() false 1322 // on the way back in, so the reload actually reaches the sign-in 1323 // gate instead of bouncing forever. Do not simplify this to a plain 1324 // reload(). 1325 if (e && e.status === 401) { 1326 if (window.BFAuth && typeof window.BFAuth.logout === 'function') { 1327 window.BFAuth.logout(); 1328 } else { 1329 try { 1330 localStorage.removeItem('dta_authToken'); 1331 localStorage.removeItem('dta_playerId'); 1332 } catch (e2) { 1333 /* private mode / disabled storage -- nothing more we can do */ 1334 } 1335 } 1336 window.location.reload(); 1337 return; 1338 } 1339 S.error = (e && e.message) || 'Failed to load'; 1340 render(); 1341 }); 1342 } 1343 1344 window.TDTT = window.TDTT || {}; 1345 window.TDTT.appSections = window.TDTT.appSections || {}; 1346 window.TDTT.appSections.profile = { 1347 mount: function (el) { 1348 root = el; 1349 // Resolves ?tab= up front so the first paint (once load() below 1350 // resolves) already shows the right tab -- see load()'s success 1351 // handler for where that tab's own lazy fetch actually starts. 1352 S.activeTab = resolveActiveTab(readTabParam()); 1353 load(); 1354 // Outside #app-content, so unmount() MUST remove this -- see 1355 // onTabPopState()'s own header comment and community.js's identical 1356 // mount()/unmount() wiring. 1357 window.addEventListener('popstate', onTabPopState); 1358 }, 1359 unmount: function () { 1360 seq++; // invalidate any in-flight response 1361 achvSeq++; // invalidate any in-flight achievements fetch 1362 if (decksMount) { 1363 decksMount.destroy(); 1364 decksMount = null; 1365 } 1366 // ditto, per lazy data tab -- incremented (never reset to 0) so a 1367 // stale in-flight fetch's own captured counter value can never 1368 // coincidentally match a future counter value again, same reasoning 1369 // as seq/achvSeq. 1370 for (var i = 0; i < LAZY_DATA_TABS.length; i++) { 1371 tabDataSeq[LAZY_DATA_TABS[i]] = (tabDataSeq[LAZY_DATA_TABS[i]] || 0) + 1; 1372 } 1373 window.removeEventListener('popstate', onTabPopState); 1374 root = null; 1375 S.profile = null; 1376 S.error = null; 1377 S.activeTab = 'overview'; 1378 S.achv = { status: 'idle', defs: null, rows: null, error: null }; 1379 S.achvUi = { category: null, query: '', sort: 'progress' }; 1380 S.tabData = { 1381 'game-stats': emptyTabState(), 1382 heroes: emptyTabState(), 1383 'match-history': emptyTabState(), 1384 }; 1385 S.gameStatsGame = 'ALL'; 1386 S.heroesUi = { sort: 'games', archetype: null, page: 1, selectedSlug: null }; 1387 }, 1388 }; 1389})();
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.