// UI management and event handlers var UI = { elements: {}, init: function() { // Cache DOM elements this.elements = { totalMass: document.getElementById('total-mass'), spendableMass: document.getElementById('spendable-mass'), totalLevel: document.getElementById('total-level'), asteroidLevel: document.getElementById('asteroid-level'), cometLevel: document.getElementById('comet-level'), planetLevel: document.getElementById('planet-level'), giantLevel: document.getElementById('giant-level'), mtypeLevel: document.getElementById('mtype-level'), ktypeLevel: document.getElementById('ktype-level'), gtypeLevel: document.getElementById('gtype-level'), asteroidUpgradeBtn: document.getElementById('asteroid-upgrade-btn'), cometUpgradeBtn: document.getElementById('comet-upgrade-btn'), planetUpgradeBtn: document.getElementById('planet-upgrade-btn'), giantUpgradeBtn: document.getElementById('giant-upgrade-btn'), mtypeUpgradeBtn: document.getElementById('mtype-upgrade-btn'), ktypeUpgradeBtn: document.getElementById('ktype-upgrade-btn'), gtypeUpgradeBtn: document.getElementById('gtype-upgrade-btn'), gearIcon: document.getElementById('gear-icon'), settingsMenu: document.getElementById('settings-menu'), resetBtn: document.getElementById('reset-btn'), leaderboardModal: document.getElementById('leaderboardModal'), leaderboardToggle: document.getElementById('leaderboardToggle'), leaderboardPanel: document.getElementById('leaderboardPanel'), leaderboardList: document.getElementById('leaderboardList'), sortMass: document.getElementById('sortMass'), sortAge: document.getElementById('sortAge'), massRate: document.getElementById('massRate') }; // Sorting buttons this.elements.sortMass.addEventListener('click', () => this.openLeaderboard('mass')); this.elements.sortAge.addEventListener('click', () => this.openLeaderboard('age')); this.setupEventListeners(); }, setupEventListeners: function() { const self = this; const gear = self.elements.gearIcon; const settingsMenu = self.elements.settingsMenu; const leaderboardToggle = self.elements.leaderboardToggle; const leaderboardPanel = self.elements.leaderboardPanel; const resetBtn = self.elements.resetBtn; const holeIcon = document.getElementById('hole-icon'); const upgradePanel = document.getElementById('upgrade-panel'); // ---------- Upgrade Panel ---------- if (holeIcon && upgradePanel) { // Toggle upgrade panel holeIcon.addEventListener('click', (e) => { e.stopPropagation(); if (settingsMenu) settingsMenu.classList.remove('open'); if (leaderboardPanel) leaderboardPanel.classList.remove('show'); upgradePanel.classList.toggle('open'); }); // Prevent clicks inside panel from closing it upgradePanel.addEventListener('click', (e) => { e.stopPropagation(); }); } // ---------- Leaderboard ---------- if (leaderboardToggle && leaderboardPanel) { // Toggle leaderboard panel leaderboardToggle.addEventListener('click', (e) => { e.stopPropagation(); // prevent document click if (settingsMenu) settingsMenu.classList.remove('open'); if (upgradePanel) upgradePanel.classList.remove('open'); leaderboardPanel.classList.toggle('show'); if (leaderboardPanel.classList.contains('show')) { self.openLeaderboard('mass'); // load leaderboard by mass immediately } }); // Prevent clicks inside the panel from closing it leaderboardPanel.addEventListener('click', (e) => { e.stopPropagation(); }); } if (resetBtn) { resetBtn.addEventListener('click', function() { Storage.resetGame(); }); } // ---------- Settings Menu ---------- if (gear && settingsMenu) { // Toggle settings menu gear.addEventListener('click', (e) => { e.stopPropagation(); // prevent document click from immediately closing if (upgradePanel) upgradePanel.classList.remove('open'); if (leaderboardPanel) leaderboardPanel.classList.remove('show'); settingsMenu.classList.toggle('open'); }); // Prevent clicks inside menu from closing it settingsMenu.addEventListener('click', (e) => { e.stopPropagation(); }); } // ---------- Session Transfer ---------- var exportBtn = document.getElementById('export-session-btn'); var claimBtn = document.getElementById('transfer-claim-btn'); if (exportBtn) { exportBtn.addEventListener('click', function(e) { e.stopPropagation(); self.exportSession(); }); } if (claimBtn) { claimBtn.addEventListener('click', async function(e) { e.stopPropagation(); var input = document.getElementById('transfer-code-input'); var isVisible = input.style.display !== 'none'; if (!isVisible) { input.style.display = 'block'; claimBtn.classList.add('active'); input.focus(); return; } var code = input.value.trim(); if (!code) { input.style.display = 'none'; claimBtn.classList.remove('active'); return; } if (!/^[0-9a-fA-F]{5}$/.test(code)) { showErrorNotification('Invalid or expired code'); return; } try { var result = await Server.claimTransferCode(code.toLowerCase()); if (!result || !result.playerId) throw new Error(); Storage.setCookie('playerId', result.playerId, 365); Storage.setCookie('sessionToken', result.sessionToken, 365); showSuccessNotification('Session imported — reloading...'); setTimeout(function() { location.reload(); }, 1500); } catch (err) { showErrorNotification('Invalid or expired code'); } }); } var transferInput = document.getElementById('transfer-code-input'); if (transferInput) { transferInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.stopPropagation(); claimBtn.click(); } }); } // ---------- Click outside to close ---------- document.addEventListener('click', () => { // Close settings menu if (settingsMenu) { settingsMenu.classList.remove('open'); } // Close leaderboard panel if (leaderboardPanel) { leaderboardPanel.classList.remove('show'); // Clear refresh intervals when closing if (this._leaderboardRefreshInterval) { clearInterval(this._leaderboardRefreshInterval); this._leaderboardRefreshInterval = null; } if (this._leaderboardIndicatorInterval) { clearInterval(this._leaderboardIndicatorInterval); this._leaderboardIndicatorInterval = null; } } if (upgradePanel) { upgradePanel.classList.remove('open'); } }); }, setUpgradeHandlers: function(handlers) { this.elements.asteroidUpgradeBtn.addEventListener('click', handlers.asteroid); this.elements.cometUpgradeBtn.addEventListener('click', handlers.comet); this.elements.planetUpgradeBtn.addEventListener('click', handlers.planet); this.elements.giantUpgradeBtn.addEventListener('click', handlers.giant); this.elements.mtypeUpgradeBtn.addEventListener('click', handlers.mtype); this.elements.ktypeUpgradeBtn.addEventListener('click', handlers.ktype); this.elements.gtypeUpgradeBtn.addEventListener('click', handlers.gtype); }, update: function(gameState, config) { if (this.elements.spendableMass) { this.elements.spendableMass.textContent = 'Available to Spend: ' + this.formatMass(gameState.totalMassConsumed); } this.updateMassDisplay(gameState, config); this.updateRateDisplay(gameState, config); this.updateUpgradeDisplay(gameState); this.updateTotalLevel(gameState); this.updateActiveObjects(); }, updateMassDisplay: function(state, config) { const bhMassText = this.formatMass(state.blackHoleTotalMass, { forceSolarMass: true }); const consumedText = this.formatMass(state.totalMassConsumedEver); this.elements.totalMass.innerHTML = ` Black Hole Mass: ${bhMassText} The mass of your black hole in solar masses.
1 Solar Mass (M☉) ≈ 1.989 × 10³⁰ kg.

Total mass absorbed:
${consumedText}
`; }, updateRateDisplay: function(state, config) { const theoretical = calculateTheoreticalRate(state, config); const windowedTheoretical = calculateTheoreticalRate(state, config, 1000); const trend = state.rateShortTrend || 'same'; const rateShortText = this.formatMass(theoretical) + '/s'; const rateMediumText = this.formatMass(theoretical * 3600) + '/h'; const rateLongText = this.formatMass(theoretical * 86400) + '/d'; const delta = (state.rateShort || 0) - windowedTheoretical; const sign = delta >= 0 ? '+' : '-'; const deltaText = sign + this.formatMass(Math.abs(delta)) + '/s'; const deltaClass = trend === 'same' ? 'rate-same' : 'rate-' + trend; const rateText = ` ${rateShortText}  ${deltaText}
         ${rateMediumText}
         ${rateLongText} `; this.elements.massRate.innerHTML = `   Rates: ${rateText} `; }, updateUpgradeDisplay: function(gameState) { this.updateAsteroidUpgrade(gameState); // Only show comet upgrade if unlocked if (gameState.cometUnlocked) { this.updateCometUpgrade(gameState); this.elements.cometLevel.parentElement.style.display = ''; } else { this.elements.cometLevel.parentElement.style.display = 'none'; } // Only show planet upgrade if unlocked if (gameState.planetUnlocked) { this.updatePlanetUpgrade(gameState); this.elements.planetLevel.parentElement.style.display = ''; } else { this.elements.planetLevel.parentElement.style.display = 'none'; } // Only show giant upgrade if unlocked if (gameState.giantUnlocked) { this.updateGiantUpgrade(gameState); this.elements.giantLevel.parentElement.style.display = ''; } else { this.elements.giantLevel.parentElement.style.display = 'none'; } // Only show mtype upgrade if unlocked if (gameState.mtypeUnlocked) { this.updateMtypeUpgrade(gameState); this.elements.mtypeLevel.parentElement.style.display = ''; } else { this.elements.mtypeLevel.parentElement.style.display = 'none'; } // Only show ktype upgrade if unlocked if (gameState.ktypeUnlocked) { this.updateKtypeUpgrade(gameState); this.elements.ktypeLevel.parentElement.style.display = ''; } else { this.elements.ktypeLevel.parentElement.style.display = 'none'; } // Only show gtype upgrade if unlocked if (gameState.gtypeUnlocked) { this.updateGtypeUpgrade(gameState); this.elements.gtypeLevel.parentElement.style.display = ''; } else { this.elements.gtypeLevel.parentElement.style.display = 'none'; } var canAffordAny = gameState.totalMassConsumed >= gameState.asteroidUpgradeCost || (gameState.cometUnlocked && gameState.totalMassConsumed >= gameState.cometUpgradeCost) || (gameState.planetUnlocked && gameState.totalMassConsumed >= gameState.planetUpgradeCost) || (gameState.giantUnlocked && gameState.totalMassConsumed >= gameState.giantUpgradeCost) || (gameState.mtypeUnlocked && gameState.totalMassConsumed >= gameState.mtypeUpgradeCost) || (gameState.ktypeUnlocked && gameState.totalMassConsumed >= gameState.ktypeUpgradeCost) || (gameState.gtypeUnlocked && gameState.totalMassConsumed >= gameState.gtypeUpgradeCost); var holeIcon = document.getElementById('hole-icon'); if (holeIcon) { if (canAffordAny) { holeIcon.classList.add('pulse'); } else { holeIcon.classList.remove('pulse'); } } }, updateTotalLevel: function(gameState) { var el = this.elements.totalLevel; if (!el) return; var totalLevel = gameState.asteroidUpgradeLevel + gameState.cometUpgradeLevel + gameState.planetUpgradeLevel + gameState.giantUpgradeLevel + gameState.mtypeUpgradeLevel + gameState.ktypeUpgradeLevel + gameState.gtypeUpgradeLevel; el.innerHTML = '' + 'Total Level: ' + totalLevel + '' + 'Sum of all upgrades.
' + '
' + '
'; var starBonusEl = document.getElementById('star-bonus'); if (!starBonusEl) return; var mBonus = gameState.mtypeUpgradeLevel * CONFIG.MTYPE_SPAWN_BOOST_PER_LEVEL * 100; var kBonus = gameState.ktypeUpgradeLevel * CONFIG.KTYPE_SPAWN_BOOST_PER_LEVEL * 100; var gBonus = gameState.gtypeUpgradeLevel * CONFIG.GTYPE_SPAWN_BOOST_PER_LEVEL * 100; var totalStarBonus = (mBonus + kBonus + gBonus).toFixed(0); var starTooltip = '*-Type upgrades boost the spawn rate of all objects.'; if (mBonus > 1) { starTooltip += '

M-Type: +' + mBonus.toFixed(0) + '%
'; } if (kBonus > 1) { starTooltip += 'K-Type: +' + kBonus.toFixed(0) + '%
'; } if (gBonus > 1) { starTooltip += 'G-Type: +' + gBonus.toFixed(0) + '%'; } starBonusEl.innerHTML = '' + '
Star Bonus: +' + totalStarBonus + '%' + '' + starTooltip + '' + '
'; }, updateActiveObjects: function() { var el = document.getElementById('active-objects'); if (!el) return; var count = window.gameAsteroids ? window.gameAsteroids.length : 0; var now = Date.now(); // Initialize tracking if (this._lastObjectCount === undefined) { this._lastObjectCount = count; this._objectTrend = 'same'; this._lastObjectCheck = now; } // Only update trend once per second to avoid flicker if (now - this._lastObjectCheck >= 1000) { if (count > this._lastObjectCount) { this._objectTrend = 'up'; } else if (count < this._lastObjectCount) { this._objectTrend = 'down'; } else { this._objectTrend = 'same'; } this._lastObjectCount = count; this._lastObjectCheck = now; } // Apply trend color el.innerHTML = 'Objects: ' + count + ''; }, updateAsteroidUpgrade: function(gameState) { var rate = (1000 / gameState.currentAsteroidSpawnInterval).toFixed(2); var bonusPercent = (gameState.asteroidUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_ASTER * 100).toFixed(0); var tooltipText = 'Asteroids are rocky, airless bodies that range in size from tiny pebbles to hundreds of kilometers across. Some asteroids have moons or even binary companions.

Rate: ' + rate + '/sec
Bonus: ' + bonusPercent + '%'; if (!gameState.cometUnlocked) tooltipText += '
Unlocks Comets at level 20'; this.elements.asteroidLevel.innerHTML = 'Asteroids: Level ' + gameState.asteroidUpgradeLevel + '' + tooltipText + ''; this.elements.asteroidUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.asteroidUpgradeCost) + ')'; this.elements.asteroidUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.asteroidUpgradeCost; }, updateCometUpgrade: function(gameState) { var rate = (60000 / gameState.currentCometSpawnInterval).toFixed(2); var bonusPercent = (gameState.cometUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_COMET * 100).toFixed(0); var tooltipText = 'Comets are small icy bodies that typically release gas and dust, forming a glowing coma and often a tail. They are composed mainly of ice, rock, and organic compounds.

Rate: ' + rate + '/min
Bonus: ' + bonusPercent + '%'; if (!gameState.planetUnlocked) tooltipText += '
Unlocks Planets at level 15'; this.elements.cometLevel.innerHTML = 'Comets: Level ' + gameState.cometUpgradeLevel + '' + tooltipText + ''; this.elements.cometUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.cometUpgradeCost) + ')'; this.elements.cometUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.cometUpgradeCost; }, updatePlanetUpgrade: function(gameState) { var rate = (CONFIG.BASE_PLANET_SPAWN_INTERVAL / gameState.currentPlanetSpawnInterval).toFixed(2); var bonusPercent = (gameState.planetUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_PLANT * 100).toFixed(0); var tooltipText = 'Planets are roughly spherical accumulations of rock and metal, with solid surfaces and relatively thin atmospheres. Local examples include Mercury, Earth, and Pluto. They may vary greatly in size.

Rate: ' + rate + '/hour
Bonus: ' + bonusPercent + '%'; if (!gameState.giantUnlocked) tooltipText += '
Unlocks Giants at level 10'; this.elements.planetLevel.innerHTML = 'Planets: Level ' + gameState.planetUpgradeLevel + '' + tooltipText + ''; this.elements.planetUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.planetUpgradeCost) + ')'; this.elements.planetUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.planetUpgradeCost; }, updateGiantUpgrade: function(gameState) { var rate = (CONFIG.BASE_GIANT_SPAWN_INTERVAL / gameState.currentGiantSpawnInterval).toFixed(2); var bonusPercent = (gameState.giantUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GIANT * 100).toFixed(0); var tooltipText = 'Gas giants are large planets made mostly of hydrogen and helium, with thick atmospheres and no solid surface.
Ice giants are similar but contain higher amounts of frozen materials like water, ammonia, and methane in their atmospheres.
Local examples include Jupiter and Neptune.

Rate: ' + rate + '/6hours
Bonus: ' + bonusPercent + '%'; if (!gameState.mtypeUnlocked) tooltipText += '
Unlocks M-Type at level 5'; this.elements.giantLevel.innerHTML = 'Giants: Level ' + gameState.giantUpgradeLevel + '' + tooltipText + ''; this.elements.giantUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.giantUpgradeCost) + ')'; this.elements.giantUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.giantUpgradeCost; }, updateMtypeUpgrade: function(gameState) { var rate = (CONFIG.BASE_MTYPE_SPAWN_INTERVAL / gameState.currentMtypeSpawnInterval).toFixed(2); var bonusPercent = (gameState.mtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_MTYPE * 100).toFixed(0); var tooltipText = 'M-types, also known as red dwarfs, are the smallest and coolest stars with masses ranging from about 0.08 to 0.45 M☉.
They are the most common star in the universe, making up roughly three quarters of all main-sequence stars, but are not easily visible due to their low luminosity.

Rate: ' + rate + '/day
Bonus: ' + bonusPercent + '%'; if (!gameState.ktypeUnlocked) tooltipText += '
Unlocks K-Type at level 5'; this.elements.mtypeLevel.innerHTML = 'M-Type: Level ' + gameState.mtypeUpgradeLevel + '' + tooltipText + ''; this.elements.mtypeUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.mtypeUpgradeCost) + ')'; this.elements.mtypeUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.mtypeUpgradeCost; }, updateKtypeUpgrade: function(gameState) { var rate = (CONFIG.BASE_KTYPE_SPAWN_INTERVAL / gameState.currentKtypeSpawnInterval).toFixed(2); var bonusPercent = (gameState.ktypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_KTYPE * 100).toFixed(0); var tooltipText = 'K-types, also known as orange dwarfs, are medium-sized stars that are cooler than the Sun, with masses ranging from about 0.45 to 0.8 M☉. They are known for their stability and long lifespans (20 to 70 billion years), making them potential candidates for supporting inhabited planets.

Rate: ' + rate + '/2days
Bonus: ' + bonusPercent + '%'; if (!gameState.gtypeUnlocked) tooltipText += '
Unlocks G-Type at level 5'; this.elements.ktypeLevel.innerHTML = 'K-Type: Level ' + gameState.ktypeUpgradeLevel + '' + tooltipText + ''; this.elements.ktypeUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.ktypeUpgradeCost) + ')'; this.elements.ktypeUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.ktypeUpgradeCost; }, updateGtypeUpgrade: function(gameState) { var rate = (CONFIG.BASE_GTYPE_SPAWN_INTERVAL / gameState.currentGtypeSpawnInterval).toFixed(2); var bonusPercent = (gameState.gtypeUpgradeLevel * CONFIG.UPGRADE_BONUS_PER_LEVEL_GTYPE * 100).toFixed(0); var tooltipText = 'G-Types, also known as yellow dwarfs, are medium-sized stars that are about the size of our Sun (~1 M☉). The term yellow dwarf is a misnomer, as most G-Types (including the Sun) are in fact white. These stars are stable and long-lived, making them strong candidates for hosting habitable planets.

Rate: ' + rate + '/3days
Bonus: ' + bonusPercent + '%'; this.elements.gtypeLevel.innerHTML = 'G-Type: Level ' + gameState.gtypeUpgradeLevel + '' + tooltipText + ''; this.elements.gtypeUpgradeBtn.textContent = 'Upgrade (Cost: ' + this.formatMass(gameState.gtypeUpgradeCost) + ')'; this.elements.gtypeUpgradeBtn.disabled = gameState.totalMassConsumed < gameState.gtypeUpgradeCost; }, formatMass: function(massKg, options = {}) { if (massKg == null) return '0'; // fallback const SOLAR_SWITCH_KG = CONFIG.SOLAR_MASS_KG * 0.01; // ~1% solar mass // Forced solar mass display (e.g. black holes) if (options.forceSolarMass || massKg >= SOLAR_SWITCH_KG) { var solarMasses = massKg / CONFIG.SOLAR_MASS_KG; // Precision scales nicely with magnitude if (solarMasses >= 1000) return solarMasses.toFixed(0) + ' M☉'; if (solarMasses >= 1) return solarMasses.toFixed(3) + ' M☉'; return solarMasses.toFixed(5) + ' M☉'; } // SI mass units (tonnes-based) if (massKg >= 1e30) return (massKg / 1e30).toFixed(2) + ' Qt'; // Quettatonnes if (massKg >= 1e27) return (massKg / 1e27).toFixed(2) + ' Rt'; // Ronnatonnes if (massKg >= 1e24) return (massKg / 1e24).toFixed(2) + ' Yt'; // Yottatonnes if (massKg >= 1e21) return (massKg / 1e21).toFixed(2) + ' Zt'; // Zettatonnes if (massKg >= 1e18) return (massKg / 1e18).toFixed(2) + ' Et'; // Exatonnes if (massKg >= 1e15) return (massKg / 1e15).toFixed(2) + ' Pt'; // Petatonnes if (massKg >= 1e12) return (massKg / 1e12).toFixed(2) + ' Tt'; // Teratonnes if (massKg >= 1e9) return (massKg / 1e9).toFixed(2) + ' Gt'; // Gigatonnes if (massKg >= 1e6) return (massKg / 1e6).toFixed(2) + ' Mt'; // Megatonnes if (massKg >= 1e3) return (massKg / 1e3).toFixed(2) + ' tonnes'; // Tonnes return massKg.toFixed(0) + ' kg'; }, formatTime: function(ms) { var seconds = Math.floor(ms / 1000); var minutes = Math.floor(seconds / 60); var hours = Math.floor(minutes / 60); var days = Math.floor(hours / 24); if (days > 0) { return days + 'd ' + (hours % 24) + 'h'; } else if (hours > 0) { return hours + 'h ' + (minutes % 60) + 'm'; } else if (minutes > 0) { return minutes + 'm ' + (seconds % 60) + 's'; } else { return seconds + 's'; } }, formatAge: function(ms) { var days = Math.floor(ms / (24 * 60 * 60 * 1000)); var hours = Math.floor((ms % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)); if (days > 0) { return days + 'd ' + hours + 'h'; } else if (hours > 0) { return hours + 'h'; } else { return 'New'; } }, openLeaderboard: async function(sortBy = 'mass') { const container = this.elements.leaderboardList; const localPlayerId = Server.playerId; // Show loading only on first load if (!this._cachedLeaderboardData) { container.innerHTML = '
Loading...
'; } // Fetch leaderboard let data = await Server.getLeaderboard(sortBy); if (!data.length) { container.innerHTML = '
No entries yet
'; return; } this._cachedLeaderboardData = data; this._currentSort = sortBy; // Render function (can be called independently) const render = () => { const now = Date.now(); const fifteenMinutes = 15 * 60 * 1000; let html = ''; data.forEach((entry, i) => { const rank = i + 1; const medal = rank===1?'🥇':rank===2?'🥈':rank===3?'🥉':rank+'.'; const isLocal = entry.id === localPlayerId; // Calculate online status in real-time const isOnline = (now - entry.lastSeen) < fifteenMinutes; const onlineIndicator = isOnline ? '' : ''; const nameSuffix = isLocal ? '💗' : ''; const displayName = entry.id ? entry.id.slice(-5) : 'Anon'; // Calculate time since last seen const timeSinceLastSeen = now - entry.lastSeen; const lastSeenText = formatTimeSince(timeSinceLastSeen); html += `
${medal} ${this.escapeHtml(displayName)}${onlineIndicator}${nameSuffix} ${lastSeenText} ${this.formatMass(entry.mass,{forceSolarMass:true})} ${this.formatAge(entry.holeAge)}
`; }); container.innerHTML = html; }; render(); // Auto-refresh data every 30 seconds if (this._leaderboardRefreshInterval) { clearInterval(this._leaderboardRefreshInterval); } this._leaderboardRefreshInterval = setInterval(async () => { if (this.elements.leaderboardPanel.classList.contains('show')) { // Re-fetch data data = await Server.getLeaderboard(sortBy); this._cachedLeaderboardData = data; } else { clearInterval(this._leaderboardRefreshInterval); this._leaderboardRefreshInterval = null; } }, 30000); // Re-render indicators every 5 seconds (lightweight, no API call) if (this._leaderboardIndicatorInterval) { clearInterval(this._leaderboardIndicatorInterval); } this._leaderboardIndicatorInterval = setInterval(() => { if (this.elements.leaderboardPanel.classList.contains('show')) { render(); // Just re-render with updated time calculations } else { clearInterval(this._leaderboardIndicatorInterval); this._leaderboardIndicatorInterval = null; } }, 5000); }, escapeHtml: function(text) { if (!text) return ''; return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, exportSession: async function() { var btn = document.getElementById('export-session-btn'); var display = document.getElementById('export-code-display'); var codeEl = document.getElementById('transfer-code-value'); var expiryEl = document.getElementById('transfer-code-expiry'); if (!btn || !display || !codeEl) return; btn.disabled = true; btn.textContent = 'Export'; display.style.display = 'none'; try { var result = await Server.createTransferCode(); if (!result || !result.code) { showErrorNotification(result && result.error ? result.error : 'Failed to generate transfer code'); return; } codeEl.textContent = result.code.toUpperCase(); display.style.display = 'block'; btn.classList.add('menu-btn-export-active'); var expiresAt = result.expiresAt; if (this._transferTimer) clearInterval(this._transferTimer); var self = this; var tick = function() { var remaining = Math.max(0, expiresAt - Date.now()); var mins = Math.floor(remaining / 60000); var secs = Math.floor((remaining % 60000) / 1000); if (expiryEl) expiryEl.textContent = 'Expires in ' + mins + ':' + (secs < 10 ? '0' : '') + secs; if (remaining === 0) { clearInterval(self._transferTimer); if (codeEl) codeEl.textContent = 'EXPIRED'; if (expiryEl) expiryEl.textContent = ''; btn.classList.remove('menu-btn-export-active'); } }; tick(); // run immediately this._transferTimer = setInterval(tick, 1000); } catch (e) { showErrorNotification('Failed to generate transfer code'); } btn.disabled = false; btn.textContent = 'Export'; } };