PageSourceSearch

https://topdowntabletop.com/app/profile.js

js topdowntabletop.com collected 2026-10-08 09:16:19 UTC 64,917 bytes, 1,389 lines download raw bytes

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.