PageSourceSearch

https://coloradohockeynow.com/wp-content/plugins/nhl-press-box-3.0.18/assets/js/press-box.js?ver=3.1.3

js coloradohockeynow.com collected 2026-09-25 03:55:58 UTC 22,176 bytes, 767 lines download raw bytes

1/**
2 * Press Box — Front-end JavaScript.
3 *
4 * - Converts UTC game times to the visitor's local timezone.
5 * - Auto-refreshes live game data every 30 seconds and updates the DOM.
6 * - Pauses polling when the browser tab is hidden.
7 */
8
9/* global wpApiSettings */
10
11( function () {
12	'use strict';
13
14	const REFRESH_INTERVAL = 30000; // 30 seconds for live games.
15	let refreshTimer = null;
16
17	/**
18	 * Resolve a REST endpoint URL localized by pb_enqueue_assets().
19	 *
20	 * Falls back to the legacy wpApiSettings/'/wp-json/' chain only if the
21	 * localized data is missing. That fallback breaks on subdirectory installs
22	 * and plain permalinks, so it exists purely as a last resort.
23	 *
24	 * @param {string} name Endpoint key from pbSettings.endpoints.
25	 * @param {string} path REST path used to build the fallback URL.
26	 * @return {string} Endpoint URL.
27	 */
28	function restEndpoint( name, path ) {
29		const endpoints = ( window.pbSettings && window.pbSettings.endpoints ) || {};
30		if ( endpoints[ name ] ) {
31			return endpoints[ name ];
32		}
33		const base = ( typeof wpApiSettings !== 'undefined' && wpApiSettings.root )
34			? wpApiSettings.root
35			: '/wp-json/';
36		return base + path;
37	}
38
39	/**
40	 * Append query params to an endpoint URL.
41	 *
42	 * Uses the URL API so params are appended with the correct separator —
43	 * the plain-permalink endpoint form already carries a query string
44	 * (?rest_route=/press-box/v1/...), where a naive '?' would corrupt it.
45	 *
46	 * @param {string} url    Endpoint URL.
47	 * @param {Object} params Query parameters.
48	 * @return {string} URL with params applied.
49	 */
50	function withParams( url, params ) {
51		const parsed = new URL( url, window.location.origin );
52		for ( const key of Object.keys( params ) ) {
53			parsed.searchParams.set( key, params[ key ] );
54		}
55		return parsed.toString();
56	}
57
58	/**
59	 * Convert all [data-utc] time elements to the visitor's local time.
60	 */
61	function convertToLocalTimes( root ) {
62		const scope    = root || document;
63		const elements = scope.querySelectorAll( '[data-utc]' );
64
65		for ( const el of elements ) {
66			const utc = el.getAttribute( 'data-utc' );
67			if ( ! utc ) {
68				continue;
69			}
70
71			try {
72				const date = new Date( utc );
73				if ( isNaN( date.getTime() ) ) {
74					continue;
75				}
76
77				const formatted = new Intl.DateTimeFormat( undefined, {
78					hour: 'numeric',
79					minute: '2-digit',
80				} ).format( date );
81
82				el.textContent = formatted;
83			} catch ( e ) {
84				// Keep server-rendered fallback on error.
85			}
86		}
87	}
88
89	/**
90	 * Check if any game cards or schedule hero on the page are live.
91	 *
92	 * @return {boolean} True if live games are detected.
93	 */
94	function hasLiveGames() {
95		if ( document.querySelectorAll( '.pb-game--live, .pb-schedule__hero--live' ).length > 0 ) {
96			return true;
97		}
98
99		// A schedule hero showing "upcoming" may be stale — if the game's
100		// start time has passed, start polling so we can transition it to live.
101		const hero = document.querySelector( '.pb-schedule__hero[data-game-id]' );
102		if ( hero ) {
103			const timeEl = hero.querySelector( '[data-utc]' );
104			if ( timeEl ) {
105				const utc = timeEl.getAttribute( 'data-utc' );
106				const start = new Date( utc );
107				if ( ! isNaN( start.getTime() ) && start.getTime() <= Date.now() ) {
108					return true;
109				}
110			}
111		}
112
113		return false;
114	}
115
116	/**
117	 * Format a period number as ordinal (1st, 2nd, 3rd, OT).
118	 *
119	 * @param {number} period Period number.
120	 * @return {string} Ordinal string.
121	 */
122	function formatPeriod( period ) {
123		switch ( period ) {
124			case 1:
125				return '1st';
126			case 2:
127				return '2nd';
128			case 3:
129				return '3rd';
130			default:
131				return 'OT';
132		}
133	}
134
135	/**
136	 * Update a game card with fresh data (scores, status, team positions).
137	 *
138	 * @param {Element} card Game card DOM element.
139	 * @param {Object}  game Normalized game data from the REST API.
140	 */
141	function updateGameCard( card, game ) {
142		const center = card.querySelector( '.pb-game__center' );
143		if ( ! center ) {
144			return;
145		}
146
147		const isLive = ( game.state === 'LIVE' || game.state === 'CRIT' );
148		const isFinal = ( game.state === 'FINAL' || game.state === 'OFF' );
149
150		// Leading/winning team goes left; tied or scheduled keeps away/home order.
151		let left = game.away;
152		let right = game.home;
153		if ( ( isLive || isFinal ) && game.home.score > game.away.score ) {
154			left = game.home;
155			right = game.away;
156		}
157
158		// Update live/final border state.
159		card.classList.toggle( 'pb-game--live', isLive );
160
161		// Update team sides.
162		const leftEl = card.querySelector( '.pb-game__team--left' );
163		const rightEl = card.querySelector( '.pb-game__team--right' );
164		if ( leftEl ) {
165			updateTeamEl( leftEl, left );
166		}
167		if ( rightEl ) {
168			updateTeamEl( rightEl, right );
169		}
170
171		if ( isLive || isFinal ) {
172			const scoreHtml = `<div class="pb-game__score">${ escapeHtml( String( left.score ) ) }\u2013${ escapeHtml( String( right.score ) ) }</div>`;
173
174			let statusHtml = '';
175			if ( isLive ) {
176				statusHtml = `<div class="pb-game__status pb-game__status--live">P${ escapeHtml( String( game.period ) ) } ${ escapeHtml( game.clock ) }</div>`;
177			} else {
178				statusHtml = '<div class="pb-game__status">Final</div>';
179			}
180
181			// Center column now holds only score + status. Three-stars and TV
182			// broadcasts live as sibling rows below pb-game__row, so they
183			// survive this innerHTML rewrite untouched.
184			center.innerHTML = scoreHtml + statusHtml;
185
186			// When a live game finalizes mid-poll, drop the TV row in favor
187			// of the three-stars row if the API returned them.
188			if ( isFinal && game.three_stars && game.three_stars.length ) {
189				const tvEl = card.querySelector( '.pb-game__tv' );
190				if ( tvEl ) {
191					tvEl.remove();
192				}
193				let starsEl = card.querySelector( '.pb-game__stars' );
194				if ( ! starsEl ) {
195					starsEl = document.createElement( 'div' );
196					starsEl.className = 'pb-game__stars';
197					card.appendChild( starsEl );
198				}
199				starsEl.textContent = formatThreeStars( game.three_stars );
200			}
201		}
202	}
203
204	/**
205	 * Update a team element's name and logo.
206	 *
207	 * @param {Element} el   Team DOM element.
208	 * @param {Object}  team Team data object.
209	 */
210	function updateTeamEl( el, team ) {
211		const nameEl = el.querySelector( '.pb-game__name' );
212		if ( nameEl ) {
213			nameEl.textContent = team.abbrev;
214		}
215		const logoEl = el.querySelector( '.pb-game__logo' );
216		if ( logoEl ) {
217			logoEl.src = team.logo || '';
218			logoEl.alt = team.abbrev;
219		}
220	}
221
222	/**
223	 * Determine hero result label and modifier class for a final game.
224	 *
225	 * @param {Element} hero The hero DOM element.
226	 * @param {Object}  game Normalized game data from the REST API.
227	 * @return {{ label: string, modifier: string, verb: string, suffix: string }} Result info.
228	 */
229	function getHeroResult( hero, game ) {
230		// Determine if the highlighted team is home or away by reading the
231		// opponent prefix rendered in the hero ("vs" = home, "@" = away).
232		const oppEl = hero.querySelector( '.pb-schedule__hero-opponent span' );
233		let isHome = true;
234		if ( oppEl ) {
235			const text = oppEl.textContent || '';
236			if ( text.indexOf( '@' ) === 0 ) {
237				isHome = false;
238			}
239		}
240
241		const teamScore = isHome ? game.home.score : game.away.score;
242		const oppScore = isHome ? game.away.score : game.home.score;
243		const periodType = game.period_type || '';
244
245		if ( teamScore > oppScore ) {
246			return { label: 'WIN', modifier: 'win', verb: 'beat the', suffix: '' };
247		} else if ( periodType === 'SO' ) {
248			return { label: 'SO LOSS', modifier: 'ot-loss', verb: 'fall to the', suffix: ' in SO' };
249		} else if ( periodType === 'OT' || game.period > 3 ) {
250			return { label: 'OT LOSS', modifier: 'ot-loss', verb: 'fall to the', suffix: ' in OT' };
251		}
252		return { label: 'LOSS', modifier: 'loss', verb: 'lose to the', suffix: '' };
253	}
254
255	/**
256	 * Update the schedule hero card with fresh game data.
257	 *
258	 * @param {Element} hero Hero card DOM element.
259	 * @param {Object}  game Normalized game data from the REST API.
260	 */
261	function updateScheduleHero( hero, game ) {
262		const isLive = ( game.state === 'LIVE' || game.state === 'CRIT' ) && game.period > 0;
263		const isFinal = ( game.state === 'FINAL' || game.state === 'OFF' );
264
265		// Update hero type class — remove old type + result modifiers.
266		hero.classList.remove(
267			'pb-schedule__hero--upcoming',
268			'pb-schedule__hero--final',
269			'pb-schedule__hero--live',
270			'pb-schedule__hero--win',
271			'pb-schedule__hero--loss',
272			'pb-schedule__hero--ot-loss'
273		);
274
275		let result;
276		if ( isLive ) {
277			hero.classList.add( 'pb-schedule__hero--live' );
278		} else if ( isFinal ) {
279			hero.classList.add( 'pb-schedule__hero--final' );
280			result = getHeroResult( hero, game );
281			hero.classList.add( `pb-schedule__hero--${ result.modifier }` );
282		} else {
283			hero.classList.add( 'pb-schedule__hero--upcoming' );
284		}
285
286		// Update label.
287		const label = hero.querySelector( '.pb-schedule__hero-label' );
288		if ( label ) {
289			if ( isLive ) {
290				label.textContent = 'LIVE';
291			} else if ( isFinal ) {
292				label.textContent = result ? result.label : 'LAST GAME';
293			} else {
294				label.textContent = 'NEXT GAME';
295			}
296		}
297
298		// Update headline text on FINAL transition.
299		if ( isFinal && result ) {
300			const teamShort = hero.getAttribute( 'data-team-short' ) || '';
301			const oppShort = hero.getAttribute( 'data-opp-short' ) || '';
302			if ( teamShort && oppShort ) {
303				const headlineSpan = hero.querySelector( '.pb-schedule__hero-opponent span' );
304				if ( headlineSpan ) {
305					headlineSpan.textContent = `${ teamShort } ${ result.verb } ${ oppShort }${ result.suffix }`;
306				}
307			}
308		}
309
310		const body = hero.querySelector( '.pb-schedule__hero-body' );
311		if ( ! body ) {
312			return;
313		}
314
315		let scoreEl = body.querySelector( '.pb-schedule__hero-score' );
316		let statusEl = body.querySelector( '.pb-schedule__hero-status' );
317
318		if ( isLive || isFinal ) {
319			// Leader first for both live and final.
320			const high = Math.max( game.away.score, game.home.score );
321			const low = Math.min( game.away.score, game.home.score );
322			const scoreText = `${ high }\u2013${ low }`;
323
324			if ( ! scoreEl ) {
325				scoreEl = document.createElement( 'div' );
326				scoreEl.className = 'pb-schedule__hero-score';
327				body.appendChild( scoreEl );
328			}
329			scoreEl.textContent = scoreText;
330
331			// Build status text.
332			let statusText;
333			let statusClass = 'pb-schedule__hero-status';
334			if ( isLive ) {
335				statusText = `${ formatPeriod( game.period ) } ${ game.clock || '' }`;
336				statusClass += ' pb-schedule__hero-status--live';
337			} else {
338				statusText = 'Final';
339			}
340
341			if ( ! statusEl ) {
342				statusEl = document.createElement( 'div' );
343				body.appendChild( statusEl );
344			}
345			statusEl.className = statusClass;
346			statusEl.textContent = statusText;
347		} else {
348			// Upcoming — remove score and status if present.
349			if ( scoreEl ) {
350				scoreEl.remove();
351			}
352			if ( statusEl ) {
353				statusEl.remove();
354			}
355		}
356	}
357
358	/**
359	 * Escape HTML entities.
360	 *
361	 * @param {string} str Raw string.
362	 * @return {string} HTML-escaped string.
363	 */
364	function escapeHtml( str ) {
365		const div = document.createElement( 'div' );
366		div.appendChild( document.createTextNode( str ) );
367		return div.innerHTML;
368	}
369
370	/**
371	 * Format three stars data into a display string.
372	 *
373	 * @param {Array} stars Array of star objects from REST API.
374	 * @return {string} Formatted string, e.g. "★ Aho (1G) | ★★ Svechnikov (2A)"
375	 */
376	function formatThreeStars( stars ) {
377		const symbols = { 1: '\u2605', 2: '\u2605\u2605', 3: '\u2605\u2605\u2605' };
378		const parts = [];
379
380		for ( const star of stars ) {
381			const symbol = symbols[ star.star ] || '';
382			const nameParts = ( star.name || '' ).split( ' ' );
383			const lastName = nameParts.length > 1 ? nameParts[ nameParts.length - 1 ] : star.name;
384			let statLine;
385
386			if ( star.position === 'G' ) {
387				statLine = star.saves != null
388					? `${ lastName } (${ star.saves } SV)`
389					: `${ lastName } (G)`;
390			} else {
391				const statParts = [];
392				if ( star.goals > 0 ) {
393					statParts.push( `${ star.goals }G` );
394				}
395				if ( star.assists > 0 ) {
396					statParts.push( `${ star.assists }A` );
397				}
398				if ( statParts.length === 0 ) {
399					statParts.push( star.position || '' );
400				}
401				statLine = `${ lastName } (${ statParts.join( ', ' ) })`;
402			}
403
404			parts.push( `${ symbol } ${ statLine }` );
405		}
406
407		return parts.join( ' | ' );
408	}
409
410	/**
411	 * Refresh games-today blocks/shortcodes via REST API and update DOM.
412	 */
413	function refreshGames() {
414		if ( ! hasLiveGames() ) {
415			stopAutoRefresh();
416			return;
417		}
418
419		const url = restEndpoint( 'gamesToday', 'press-box/v1/games-today' );
420
421		fetch( url )
422			.then( ( response ) => response.json() )
423			.then( ( json ) => {
424				if ( ! json.success || ! json.data ) {
425					return;
426				}
427
428				// Build a lookup of game data by game ID.
429				const gamesById = {};
430				for ( const day of json.data ) {
431					for ( const game of day.games ) {
432						if ( game.id ) {
433							gamesById[ game.id ] = game;
434						}
435					}
436				}
437
438				// Include upcoming games in lookup (defensive).
439				if ( json.upcoming ) {
440					for ( const day of json.upcoming ) {
441						for ( const game of day.games ) {
442							if ( game.id ) {
443								gamesById[ game.id ] = game;
444							}
445						}
446					}
447				}
448
449				// Update each game card that has a matching data-game-id.
450				const cards = document.querySelectorAll( '.pb-game[data-game-id]' );
451				for ( const card of cards ) {
452					const gameId = card.getAttribute( 'data-game-id' );
453					if ( gamesById[ gameId ] ) {
454						updateGameCard( card, gamesById[ gameId ] );
455					}
456				}
457
458				// Update schedule hero if present.
459				const hero = document.querySelector( '.pb-schedule__hero[data-game-id]' );
460				if ( hero ) {
461					const heroGameId = hero.getAttribute( 'data-game-id' );
462					if ( gamesById[ heroGameId ] ) {
463						updateScheduleHero( hero, gamesById[ heroGameId ] );
464					}
465				}
466			} )
467			.catch( () => {
468				// Silently fail — stale data is still visible.
469			} );
470	}
471
472	/**
473	 * Start auto-refresh polling when live games are detected.
474	 */
475	function startAutoRefresh() {
476		if ( refreshTimer ) {
477			return;
478		}
479
480		if ( hasLiveGames() ) {
481			refreshTimer = setInterval( refreshGames, REFRESH_INTERVAL );
482		}
483	}
484
485	/**
486	 * Stop auto-refresh polling.
487	 */
488	function stopAutoRefresh() {
489		if ( refreshTimer ) {
490			clearInterval( refreshTimer );
491			refreshTimer = null;
492		}
493	}
494
495	/**
496	 * Pause/resume polling based on tab visibility.
497	 */
498	function handleVisibilityChange() {
499		if ( document.hidden ) {
500			stopAutoRefresh();
501		} else {
502			startAutoRefresh();
503		}
504	}
505
506	/**
507	 * Activate a standings tab by view name.
508	 *
509	 * @param {Element} container Standings container element.
510	 * @param {string}  view      View name to activate.
511	 */
512	function activateTab( container, view ) {
513		const tabs = container.querySelectorAll( '.pb-standings__tab' );
514		const panels = container.querySelectorAll( '.pb-standings__panel' );
515		let found = false;
516
517		for ( const tab of tabs ) {
518			if ( tab.getAttribute( 'data-view' ) === view ) {
519				tab.classList.add( 'pb-standings__tab--active' );
520				tab.setAttribute( 'aria-selected', 'true' );
521				found = true;
522			} else {
523				tab.classList.remove( 'pb-standings__tab--active' );
524				tab.setAttribute( 'aria-selected', 'false' );
525			}
526		}
527
528		if ( ! found ) {
529			return;
530		}
531
532		for ( const panel of panels ) {
533			if ( panel.getAttribute( 'data-panel' ) === view ) {
534				panel.removeAttribute( 'hidden' );
535			} else {
536				panel.setAttribute( 'hidden', '' );
537			}
538		}
539	}
540
541	/**
542	 * Initialize standings tab switching.
543	 */
544	function initStandingsTabs() {
545		const containers = document.querySelectorAll( '.pb-standings' );
546
547		for ( const container of containers ) {
548			const tabs = container.querySelectorAll( '.pb-standings__tab' );
549
550			// Restore saved tab from localStorage.
551			try {
552				const saved = localStorage.getItem( 'pb_standings_view' );
553				if ( saved ) {
554					activateTab( container, saved );
555				}
556			} catch ( e ) {
557				// localStorage may be unavailable.
558			}
559
560			for ( const tab of tabs ) {
561				tab.addEventListener( 'click', () => {
562					const view = tab.getAttribute( 'data-view' );
563					activateTab( container, view );
564					try {
565						localStorage.setItem( 'pb_standings_view', view );
566					} catch ( e ) {
567						// localStorage may be unavailable.
568					}
569				} );
570			}
571		}
572	}
573
574	/**
575	 * Initialize sortable table headers.
576	 *
577	 * Works on any table containing th[data-sort] headers.
578	 * Text vs numeric: add data-sort-type="text" on <th> for text columns (default: numeric).
579	 * Initial sorted column: add class="pb-sortable__th--sorted" and data-sort-dir="asc|desc".
580	 */
581	function pbInitTableSort( root ) {
582		const scope     = root || document;
583		const allTables = scope.querySelectorAll( 'table' );
584
585		for ( const table of allTables ) {
586			const headers = table.querySelectorAll( 'th[data-sort]' );
587			if ( ! headers.length ) {
588				continue;
589			}
590
591			const thead = table.querySelector( 'thead' );
592			const tbody = table.querySelector( 'tbody' );
593			if ( ! thead || ! tbody ) {
594				continue;
595			}
596
597			// Build column index map: data-sort key → column index.
598			const allTh = thead.querySelectorAll( 'th' );
599			const colIndex = {};
600			allTh.forEach( ( th, idx ) => {
601				const key = th.getAttribute( 'data-sort' );
602				if ( key ) {
603					colIndex[ key ] = idx;
604				}
605			} );
606
607			for ( const th of headers ) {
608				th.addEventListener( 'click', () => {
609					const key = th.getAttribute( 'data-sort' );
610					const idx = colIndex[ key ];
611					if ( idx === undefined ) {
612						return;
613					}
614
615					// Determine direction: toggle if same column, else default to asc.
616					const currentDir = th.getAttribute( 'data-sort-dir' );
617					const dir = currentDir
618						? ( currentDir === 'asc' ? 'desc' : 'asc' )
619						: 'asc';
620
621					// Clear sorted state from all sortable headers in this table.
622					for ( const h of headers ) {
623						h.classList.remove( 'pb-sortable__th--sorted' );
624						h.removeAttribute( 'data-sort-dir' );
625					}
626
627					// Set active state.
628					th.classList.add( 'pb-sortable__th--sorted' );
629					th.setAttribute( 'data-sort-dir', dir );
630
631					// Sort rows — exclude cutoff rows (e.g. standings wildcard line).
632					const rows = Array.from(
633						tbody.querySelectorAll( 'tr:not(.pb-standings__cutoff)' )
634					);
635					const attrName = `data-sort-${ key }`;
636					const isText = th.getAttribute( 'data-sort-type' ) === 'text';
637
638					rows.sort( ( a, b ) => {
639						const aCell = a.children[ idx ];
640						const bCell = b.children[ idx ];
641						const aVal = aCell ? aCell.getAttribute( attrName ) || '' : '';
642						const bVal = bCell ? bCell.getAttribute( attrName ) || '' : '';
643
644						let cmp;
645						if ( isText ) {
646							cmp = aVal.localeCompare( bVal );
647						} else {
648							cmp = parseFloat( aVal ) - parseFloat( bVal );
649						}
650
651						return dir === 'asc' ? cmp : -cmp;
652					} );
653
654					// Remove cutoff rows (irrelevant once user sorts manually).
655					const cutoffs = tbody.querySelectorAll( '.pb-standings__cutoff' );
656					for ( const row of cutoffs ) {
657						row.remove();
658					}
659
660					// Re-append sorted rows.
661					for ( const row of rows ) {
662						tbody.appendChild( row );
663					}
664				} );
665			}
666		}
667	}
668
669	/**
670	 * Initialize Team Tag Page tab switching + lazy panel loading.
671	 *
672	 * The "Today" panel is server-rendered. The other five panels are fetched
673	 * from /wp-json/press-box/v1/team-tag/panel on first activation, then
674	 * cached in-memory on the panel element so re-clicking is instant.
675	 */
676	function initTeamTagTabs() {
677		const containers = document.querySelectorAll( '.pb-team-tag' );
678
679		for ( const container of containers ) {
680			const team    = container.getAttribute( 'data-team' );
681			const tabs    = container.querySelectorAll( '[data-pb-tab]' );
682			const panels  = container.querySelectorAll( '[data-pb-panel]' );
683			const panelUrl = restEndpoint( 'teamTagPanel', 'press-box/v1/team-tag/panel' );
684
685			if ( ! team || ! tabs.length || ! panels.length ) {
686				continue;
687			}
688
689			function activate( key ) {
690				for ( const tab of tabs ) {
691					const on = tab.getAttribute( 'data-pb-tab' ) === key;
692					tab.classList.toggle( 'pb-team-tag__tab--active', on );
693					tab.setAttribute( 'aria-selected', on ? 'true' : 'false' );
694				}
695				for ( const panel of panels ) {
696					const on = panel.getAttribute( 'data-pb-panel' ) === key;
697					panel.classList.toggle( 'pb-team-tag__panel--active', on );
698					if ( on ) {
699						panel.removeAttribute( 'hidden' );
700					} else {
701						panel.setAttribute( 'hidden', '' );
702					}
703				}
704			}
705
706			function load( key, panel ) {
707				if ( panel.dataset.pbLoaded === '1' ) {
708					return;
709				}
710				panel.dataset.pbLoaded = '1';
711
712				const url = withParams( panelUrl, { team: team, panel: key } );
713
714				fetch( url, { credentials: 'same-origin', headers: { Accept: 'application/json' } } )
715					.then( ( r ) => r.ok ? r.json() : Promise.reject( r.status ) )
716					.then( ( json ) => {
717						if ( json && json.success && json.data && typeof json.data.html === 'string' ) {
718							panel.innerHTML = json.data.html;
719							convertToLocalTimes( panel );
720							pbInitTableSort( panel );
721						} else {
722							panel.innerHTML = '<p class="pb-team-tag__panel-error">Content unavailable.</p>';
723							panel.dataset.pbLoaded = '0';
724						}
725					} )
726					.catch( () => {
727						panel.innerHTML = '<p class="pb-team-tag__panel-error">Content unavailable.</p>';
728						panel.dataset.pbLoaded = '0';
729					} );
730			}
731
732			for ( const tab of tabs ) {
733				tab.addEventListener( 'click', () => {
734					const key   = tab.getAttribute( 'data-pb-tab' );
735					const panel = container.querySelector( '[data-pb-panel="' + key + '"]' );
736					activate( key );
737					if ( panel && key !== 'today' ) {
738						load( key, panel );
739					}
740				} );
741			}
742		}
743	}
744
745	/**
746	 * Initialize on DOM ready.
747	 */
748	function init() {
749		convertToLocalTimes();
750		startAutoRefresh();
751		initStandingsTabs();
752		initTeamTagTabs();
753		pbInitTableSort();
754
755		// Fetch fresh data immediately if games are (or should be) live,
756		// rather than waiting for the first 30-second polling interval.
757		refreshGames();
758
759		document.addEventListener( 'visibilitychange', handleVisibilityChange );
760	}
761
762	if ( document.readyState === 'loading' ) {
763		document.addEventListener( 'DOMContentLoaded', init );
764	} else {
765		init();
766	}
767} )();

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.