const chartConfig = window.config.chartConfig; const coins = window.config.coins; const apiKeys = window.config.getAPIKeys(); const utils = { formatNumber: (number, decimals = 2) => { if (typeof number !== 'number' || isNaN(number)) { return '0'; } try { return new Intl.NumberFormat('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }).format(number); } catch (e) { return '0'; } }, formatDate: (timestamp, resolution) => { const date = new Date(timestamp); const options = { day: { hour: '2-digit', minute: '2-digit', hour12: true }, week: { month: 'short', day: 'numeric' }, month: { year: 'numeric', month: 'short', day: 'numeric' } }; return date.toLocaleString('en-US', { ...options[resolution], timeZone: 'UTC' }); }, debounce: (func, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => func(...args), delay); }; } }; class AppError extends Error { constructor(message, type = 'AppError') { super(message); this.name = type; } } const logger = { log: (message) => console.log(`[AppLog] ${new Date().toISOString()}: ${message}`), warn: (message) => console.warn(`[AppWarn] ${new Date().toISOString()}: ${message}`), error: (message) => console.error(`[AppError] ${new Date().toISOString()}: ${message}`) }; const api = { fetchVolumeDataXHR: async () => { const cacheKey = 'volumeData'; const cachedData = CacheManager.get(cacheKey); if (cachedData) { console.log("Using cached volume data"); return cachedData.value; } try { if (!NetworkManager.isOnline()) { throw new Error('Network is offline'); } const volumeData = await Api.fetchVolumeData({ cryptoCompare: apiKeys.cryptoCompare, coinGecko: apiKeys.coinGecko }); if (Object.keys(volumeData).length > 0) { CacheManager.set(cacheKey, volumeData, 'volume'); return volumeData; } throw new Error("No volume data found in the response"); } catch (error) { console.error("Error fetching volume data:", error); NetworkManager.handleNetworkError(error); try { const existingCache = localStorage.getItem(cacheKey); if (existingCache) { const fallbackData = JSON.parse(existingCache).value; if (fallbackData && Object.keys(fallbackData).length > 0) { return fallbackData; } } } catch (e) { console.warn("Error accessing cached volume data:", e); } return {}; } }, fetchCryptoCompareDataXHR: (coin) => { try { if (!NetworkManager.isOnline()) { throw new Error('Network is offline'); } return Api.fetchCryptoCompareData(coin, { cryptoCompare: apiKeys.cryptoCompare }); } catch (error) { logger.error(`CryptoCompare request failed for ${coin}:`, error); NetworkManager.handleNetworkError(error); const cachedData = CacheManager.get(`coinData_${coin}`); if (cachedData) { logger.info(`Using cached data for ${coin}`); return cachedData.value; } return { error: error.message }; } }, fetchCoinGeckoDataXHR: async () => { try { const priceData = await window.PriceManager.getPrices(); const transformedData = {}; const btcPriceUSD = priceData.bitcoin?.usd || 0; if (btcPriceUSD > 0) { window.btcPriceUSD = btcPriceUSD; } window.config.coins.forEach(coin => { const symbol = coin.symbol.toLowerCase(); const coinData = priceData[symbol] || priceData[coin.name.toLowerCase()]; if (coinData && coinData.usd) { let priceBtc; if (symbol === 'btc') { priceBtc = 1; } else if (window.btcPriceUSD && window.btcPriceUSD > 0) { priceBtc = coinData.usd / window.btcPriceUSD; } else { priceBtc = coinData.btc || 0; } transformedData[symbol] = { current_price: coinData.usd, price_btc: priceBtc, displayName: coin.displayName || coin.symbol, total_volume: coinData.total_volume, price_change_percentage_24h: coinData.price_change_percentage_24h }; } }); return transformedData; } catch (error) { console.error('Error in fetchCoinGeckoDataXHR:', error); return {}; } }, fetchHistoricalDataXHR: async (coinSymbols) => { if (!Array.isArray(coinSymbols)) { coinSymbols = [coinSymbols]; } const results = {}; try { if (!NetworkManager.isOnline()) { throw new Error('Network is offline'); } const historicalData = await Api.fetchHistoricalData( coinSymbols, window.config.currentResolution, { cryptoCompare: window.config.getAPIKeys().cryptoCompare } ); Object.keys(historicalData).forEach(coin => { if (historicalData[coin]) { results[coin] = historicalData[coin]; const cacheKey = `historical_${coin}_${window.config.currentResolution}`; CacheManager.set(cacheKey, historicalData[coin], 'historical'); } }); return results; } catch (error) { console.error('Error fetching historical data:', error); NetworkManager.handleNetworkError(error); for (const coin of coinSymbols) { const cacheKey = `historical_${coin}_${window.config.currentResolution}`; const cachedData = CacheManager.get(cacheKey); if (cachedData) { results[coin] = cachedData.value; } } return results; } }, }; const rateLimiter = { lastRequestTime: {}, minRequestInterval: { coingecko: window.config.rateLimits.coingecko.minInterval, cryptocompare: window.config.rateLimits.cryptocompare.minInterval }, requestQueue: {}, retryDelays: window.config.retryDelays, canMakeRequest: function(apiName) { const now = Date.now(); const lastRequest = this.lastRequestTime[apiName] || 0; return (now - lastRequest) >= this.minRequestInterval[apiName]; }, updateLastRequestTime: function(apiName) { this.lastRequestTime[apiName] = Date.now(); }, getWaitTime: function(apiName) { const now = Date.now(); const lastRequest = this.lastRequestTime[apiName] || 0; return Math.max(0, this.minRequestInterval[apiName] - (now - lastRequest)); }, queueRequest: async function(apiName, requestFn, retryCount = 0) { if (!this.requestQueue[apiName]) { this.requestQueue[apiName] = Promise.resolve(); } try { await this.requestQueue[apiName]; const executeRequest = async () => { const waitTime = this.getWaitTime(apiName); if (waitTime > 0) { await new Promise(resolve => setTimeout(resolve, waitTime)); } try { this.updateLastRequestTime(apiName); return await requestFn(); } catch (error) { if (error.message.includes('429') && retryCount < this.retryDelays.length) { const delay = this.retryDelays[retryCount]; console.log(`Rate limit hit, retrying in ${delay/1000} seconds...`); await new Promise(resolve => setTimeout(resolve, delay)); return this.queueRequest(apiName, requestFn, retryCount + 1); } if ((error.message.includes('timeout') || error.name === 'NetworkError') && retryCount < this.retryDelays.length) { const delay = this.retryDelays[retryCount]; logger.warn(`Request failed, retrying in ${delay/1000} seconds...`); await new Promise(resolve => setTimeout(resolve, delay)); return this.queueRequest(apiName, requestFn, retryCount + 1); } throw error; } }; this.requestQueue[apiName] = executeRequest(); return await this.requestQueue[apiName]; } catch (error) { if (error.message.includes('429') || error.message.includes('timeout') || error.name === 'NetworkError') { NetworkManager.handleNetworkError(error); const cachedData = CacheManager.get(`coinData_${apiName}`); if (cachedData) { return cachedData.value; } } throw error; } } }; const ui = { displayCoinData: (coin, data) => { let priceUSD, priceBTC, priceChange1d, volume24h; const updateUI = (isError = false) => { const priceUsdElement = document.querySelector(`#${coin.toLowerCase()}-price-usd`); const volumeDiv = document.querySelector(`#${coin.toLowerCase()}-volume-div`); const volumeElement = document.querySelector(`#${coin.toLowerCase()}-volume-24h`); const btcPriceDiv = document.querySelector(`#${coin.toLowerCase()}-btc-price-div`); const priceBtcElement = document.querySelector(`#${coin.toLowerCase()}-price-btc`); if (priceUsdElement) { priceUsdElement.textContent = isError ? 'N/A' : `$ ${ui.formatPrice(coin, priceUSD)}`; } if (volumeDiv && volumeElement) { if (isError || volume24h === null || volume24h === undefined) { volumeElement.textContent = 'N/A'; } else { volumeElement.textContent = `${utils.formatNumber(volume24h, 0)} USD`; } volumeDiv.style.display = volumeToggle.isVisible ? 'flex' : 'none'; } if (btcPriceDiv && priceBtcElement) { if (coin === 'BTC') { btcPriceDiv.style.display = 'none'; } else { priceBtcElement.textContent = isError ? 'N/A' : `${priceBTC.toFixed(8)}`; btcPriceDiv.style.display = 'flex'; } } ui.updatePriceChangeContainer(coin, isError ? null : priceChange1d); }; try { if (data.error) { throw new Error(data.error); } if (!data || !data.current_price) { throw new Error(`Invalid data structure for ${coin}`); } priceUSD = data.current_price; if (coin === 'BTC') { priceBTC = 1; } else { if (data.price_btc !== undefined && data.price_btc !== null) { priceBTC = data.price_btc; } else if (window.btcPriceUSD && window.btcPriceUSD > 0) { priceBTC = priceUSD / window.btcPriceUSD; } else if (app && app.btcPriceUSD && app.btcPriceUSD > 0) { priceBTC = priceUSD / app.btcPriceUSD; } else { priceBTC = 0; } } priceChange1d = data.price_change_percentage_24h || 0; volume24h = data.total_volume || 0; if (isNaN(priceUSD) || isNaN(priceBTC)) { throw new Error(`Invalid numeric values in data for ${coin}`); } updateUI(false); } catch (error) { logger.error(`Failed to display data for ${coin}:`, error.message); updateUI(true); } }, showLoader: () => { const loader = document.getElementById('loader'); if (loader) { loader.classList.remove('hidden'); } }, hideLoader: () => { const loader = document.getElementById('loader'); if (loader) { loader.classList.add('hidden'); } }, showCoinLoader: (coinSymbol) => { const loader = document.getElementById(`${coinSymbol.toLowerCase()}-loader`); if (loader) { loader.classList.remove('hidden'); } }, hideCoinLoader: (coinSymbol) => { const loader = document.getElementById(`${coinSymbol.toLowerCase()}-loader`); if (loader) { loader.classList.add('hidden'); } }, updateCacheStatus: (isCached) => { const cacheStatusElement = document.getElementById('cache-status'); if (cacheStatusElement) { cacheStatusElement.textContent = isCached ? 'Cached' : 'Live'; cacheStatusElement.classList.toggle('text-green-500', isCached); cacheStatusElement.classList.toggle('text-blue-500', !isCached); } }, updateLoadTimeAndCache: (loadTime, cachedData) => { const loadTimeElement = document.getElementById('load-time'); const cacheStatusElement = document.getElementById('cache-status'); if (loadTimeElement) { loadTimeElement.textContent = `Load time: ${loadTime}ms`; } if (cacheStatusElement) { if (cachedData && cachedData.remainingTime) { const remainingMinutes = Math.ceil(cachedData.remainingTime / 60000); cacheStatusElement.textContent = `Cached: ${remainingMinutes} min left`; cacheStatusElement.classList.add('text-green-500'); cacheStatusElement.classList.remove('text-blue-500'); } else { cacheStatusElement.textContent = 'Live'; cacheStatusElement.classList.add('text-blue-500'); cacheStatusElement.classList.remove('text-green-500'); } } ui.updateLastRefreshedTime(); }, updatePriceChangeContainer: (coin, priceChange) => { const container = document.querySelector(`#${coin.toLowerCase()}-price-change-container`); if (container) { if (priceChange === null || priceChange === undefined) { container.innerHTML = 'N/A'; } else { container.innerHTML = priceChange >= 0 ? ui.positivePriceChangeHTML(priceChange) : ui.negativePriceChangeHTML(priceChange); } } }, updateLastRefreshedTime: () => { const lastRefreshedElement = document.getElementById('last-refreshed-time'); if (lastRefreshedElement && app.lastRefreshedTime) { const formattedTime = app.lastRefreshedTime.toLocaleTimeString(); lastRefreshedElement.textContent = `Last Refreshed: ${formattedTime}`; } }, updateConnectionStatus: () => { const statusElement = document.getElementById('connection-status'); if (statusElement) { const online = NetworkManager.isOnline(); statusElement.textContent = online ? 'Connected' : 'Disconnected'; statusElement.classList.toggle('text-green-500', online); statusElement.classList.toggle('text-red-500', !online); } }, positivePriceChangeHTML: (value) => `
${value.toFixed(2)}%
`, negativePriceChangeHTML: (value) => `
${Math.abs(value).toFixed(2)}%
`, formatPrice: (coin, price) => { if (typeof price !== 'number' || isNaN(price)) { logger.error(`Invalid price for ${coin}:`, price); return 'N/A'; } if (price < 0.000001) return price.toExponential(2); if (price < 0.001) return price.toFixed(8); if (price < 1) return price.toFixed(4); if (price < 10) return price.toFixed(3); if (price < 1000) return price.toFixed(2); if (price < 100000) return price.toFixed(1); return price.toFixed(0); }, setActiveContainer: (containerId) => { const containerIds = ['btc', 'xmr', 'part', 'pivx', 'firo', 'dash', 'ltc', 'doge', 'eth', 'dcr', 'nmc', 'zano', 'wow', 'bch'].map(id => `${id}-container`); containerIds.forEach(id => { const container = document.getElementById(id); if (container) { const innerDiv = container.querySelector('div'); innerDiv.classList.toggle('active-container', id === containerId); } }); }, displayErrorMessage: (message, duration = 0) => { const errorOverlay = document.getElementById('error-overlay'); const errorMessage = document.getElementById('error-message'); const chartContainer = document.querySelector('.container-to-blur'); if (errorOverlay && errorMessage && chartContainer) { errorOverlay.classList.remove('hidden'); errorMessage.textContent = message; chartContainer.classList.add('blurred'); if (duration > 0) { setTimeout(() => { ui.hideErrorMessage(); }, duration); } } }, hideErrorMessage: () => { const errorOverlay = document.getElementById('error-overlay'); const containersToBlur = document.querySelectorAll('.container-to-blur'); if (errorOverlay) { errorOverlay.classList.add('hidden'); containersToBlur.forEach(container => container.classList.remove('blurred')); } }, showNetworkErrorMessage: () => { ui.displayErrorMessage( "Network connection lost. Data shown may be outdated. We'll automatically refresh once connection is restored.", 0 ); const errorOverlay = document.getElementById('error-overlay'); if (errorOverlay) { const reconnectBtn = document.createElement('button'); reconnectBtn.className = "mt-4 bg-blue-500 hover:bg-blue-600 text-white py-2 px-4 rounded"; reconnectBtn.textContent = "Try to Reconnect"; reconnectBtn.onclick = () => { NetworkManager.manualReconnect(); }; const buttonContainer = errorOverlay.querySelector('.button-container') || document.createElement('div'); buttonContainer.className = "button-container mt-4"; buttonContainer.innerHTML = ''; buttonContainer.appendChild(reconnectBtn); if (!errorOverlay.querySelector('.button-container')) { errorOverlay.querySelector('div').appendChild(buttonContainer); } } } }; const chartModule = { chart: null, currentCoin: 'BTC', loadStartTime: 0, chartRefs: new WeakMap(), pendingAnimationFrame: null, verticalLinePlugin: { id: 'verticalLine', beforeDraw: (chart, args, options) => { if (chart.tooltip._active && chart.tooltip._active.length) { const activePoint = chart.tooltip._active[0]; const ctx = chart.ctx; const x = activePoint.element.x; const topY = chart.scales.y.top; const bottomY = chart.scales.y.bottom; ctx.save(); ctx.beginPath(); ctx.moveTo(x, topY); ctx.lineTo(x, bottomY); ctx.lineWidth = options.lineWidth || 1; ctx.strokeStyle = options.lineColor || 'rgba(77, 132, 240, 0.5)'; ctx.stroke(); ctx.restore(); } } }, getChartByElement: function(element) { return this.chartRefs.get(element); }, setChartReference: function(element, chart) { this.chartRefs.set(element, chart); }, destroyChart: function() { if (chartModule.chart) { try { const chartInstance = chartModule.chart; const canvas = document.getElementById('coin-chart'); chartModule.chart = null; if (chartInstance && chartInstance.destroy && typeof chartInstance.destroy === 'function') { chartInstance.destroy(); } if (canvas) { chartModule.chartRefs.delete(canvas); const ctx = canvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); } } } catch (e) { console.error('Error destroying chart:', e); } } }, initChart: function() { this.destroyChart(); const canvas = document.getElementById('coin-chart'); if (!canvas) { console.error('Chart canvas element not found'); return; } canvas.style.display = 'block'; if (canvas.style.width === '1px' || canvas.style.height === '1px') { canvas.style.width = '100%'; canvas.style.height = '100%'; } const ctx = canvas.getContext('2d'); if (!ctx) { console.error('Failed to get chart context. Make sure the canvas element exists.'); return; } const gradient = ctx.createLinearGradient(0, 0, 0, 400); gradient.addColorStop(0, 'rgba(77, 132, 240, 0.2)'); gradient.addColorStop(1, 'rgba(77, 132, 240, 0)'); chartModule.chart = new Chart(ctx, { type: 'line', data: { datasets: [{ label: 'Price', data: [], borderColor: 'rgba(77, 132, 240, 1)', backgroundColor: gradient, tension: 0.4, fill: true, pointRadius: 2, pointHoverRadius: 4, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 750 }, interaction: { intersect: false, mode: 'index' }, scales: { x: { type: 'time', time: { unit: 'hour', displayFormats: { hour: 'h:mm a', day: 'MMM d', month: 'MMM yyyy' }, tooltipFormat: 'MMM d, yyyy h:mm a' }, adapters: { date: { zone: 'UTC' } }, ticks: { source: 'auto', maxTicksLimit: 12, font: { size: 12, family: "'Inter', sans-serif" }, color: 'rgba(156, 163, 175, 1)', maxRotation: 0, minRotation: 0, callback: function(value) { const date = new Date(value); if (window.config.currentResolution === 'day') { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: 'UTC' }); } else if (window.config.currentResolution === 'year') { return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' }); } else { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); } } }, grid: { display: false } }, y: { beginAtZero: false, ticks: { font: { size: 12, family: "'Inter', sans-serif" }, color: 'rgba(156, 163, 175, 1)', callback: (value) => '$' + value.toLocaleString() }, grid: { display: false } } }, plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, backgroundColor: 'rgba(255, 255, 255, 0.9)', titleColor: 'rgba(17, 24, 39, 1)', bodyColor: 'rgba(55, 65, 81, 1)', borderColor: 'rgba(226, 232, 240, 1)', borderWidth: 1, cornerRadius: 4, padding: 8, displayColors: false, callbacks: { title: (tooltipItems) => { const date = new Date(tooltipItems[0].parsed.x); if (window.config.currentResolution === 'day') { return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, timeZone: 'UTC' }); } else if (window.config.currentResolution === 'year') { return date.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); } else { return date.toLocaleString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); } }, label: (item) => { const value = item.parsed.y; return `${chartModule.currentCoin}: $${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 8 })}`; } } }, verticalLine: { lineWidth: 1, lineColor: 'rgba(77, 132, 240, 0.5)' } } }, plugins: [chartModule.verticalLinePlugin] }); this.setChartReference(canvas, chartModule.chart); if (window.CleanupManager) { window.CleanupManager.registerResource('chart', chartModule.chart, () => { chartModule.destroyChart(); }); } }, prepareChartData: function(coinSymbol, data) { if (!data) { return []; } try { let rawDataPoints = []; if (Array.isArray(data)) { rawDataPoints = data.map(([timestamp, price]) => ({ time: new Date(timestamp).getTime(), close: price })); } else if (data.Data && Array.isArray(data.Data)) { rawDataPoints = data.Data.map(d => ({ time: d.time * 1000, close: d.close })); } else if (data.Data && data.Data.Data && Array.isArray(data.Data.Data)) { rawDataPoints = data.Data.Data.map(d => ({ time: d.time * 1000, close: d.close })); } else { return []; } if (rawDataPoints.length === 0) { return []; } rawDataPoints.sort((a, b) => a.time - b.time); let preparedData = []; if (window.config.currentResolution === 'day') { const endTime = new Date(rawDataPoints[rawDataPoints.length - 1].time); endTime.setUTCMinutes(0, 0, 0); const endUnix = endTime.getTime(); const startUnix = endUnix - (24 * 3600000); for (let hourUnix = startUnix; hourUnix <= endUnix; hourUnix += 3600000) { let closestPoint = null; let closestDiff = Infinity; for (const point of rawDataPoints) { const diff = Math.abs(point.time - hourUnix); if (diff < closestDiff) { closestDiff = diff; closestPoint = point; } } if (closestPoint) { preparedData.push({ x: hourUnix, y: closestPoint.close }); } } const lastTime = rawDataPoints[rawDataPoints.length - 1].time; if (lastTime > endUnix) { preparedData.push({ x: lastTime, y: rawDataPoints[rawDataPoints.length - 1].close }); } } else { preparedData = rawDataPoints.map(point => ({ x: point.time, y: point.close })); } if (preparedData.length === 0 && rawDataPoints.length > 0) { preparedData = rawDataPoints.map(point => ({ x: point.time, y: point.close })); } return preparedData; } catch (error) { return []; } }, ensureHourlyData: function(data) { const now = new Date(); now.setUTCMinutes(0, 0, 0); const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); const hourlyData = []; for (let i = 0; i < 24; i++) { const targetTime = new Date(twentyFourHoursAgo.getTime() + i * 60 * 60 * 1000); if (data.length > 0) { const closestDataPoint = data.reduce((prev, curr) => Math.abs(new Date(curr.x).getTime() - targetTime.getTime()) < Math.abs(new Date(prev.x).getTime() - targetTime.getTime()) ? curr : prev , data[0]); hourlyData.push({ x: targetTime.getTime(), y: closestDataPoint.y }); } } return hourlyData; }, updateChart: async function(coinSymbol, forceRefresh = false) { try { if (!chartModule.chart) { chartModule.initChart(); } const currentChartData = chartModule.chart?.data?.datasets[0]?.data || []; if (currentChartData.length === 0) { chartModule.showChartLoader(); } chartModule.loadStartTime = Date.now(); const cacheKey = `chartData_${coinSymbol}_${window.config.currentResolution}`; const cachedData = !forceRefresh ? CacheManager.get(cacheKey) : null; let data; if (cachedData && Object.keys(cachedData.value).length > 0) { data = cachedData.value; } else { try { if (!NetworkManager.isOnline()) { throw new Error('Network is offline'); } const allData = await api.fetchHistoricalDataXHR([coinSymbol]); data = allData[coinSymbol]; if (!data || Object.keys(data).length === 0) { throw new Error(`No data returned for ${coinSymbol}`); } CacheManager.set(cacheKey, data, 'chart'); } catch (error) { NetworkManager.handleNetworkError(error); if (error.message.includes('429') && currentChartData.length > 0) { console.warn(`Rate limit hit for ${coinSymbol}, maintaining current chart`); chartModule.hideChartLoader(); return; } const expiredCache = localStorage.getItem(cacheKey); if (expiredCache) { try { const parsedCache = JSON.parse(expiredCache); data = parsedCache.value; } catch (cacheError) { throw error; } } else { throw error; } } } if (chartModule.currentCoin !== coinSymbol) { chartModule.destroyChart(); chartModule.initChart(); } const chartData = chartModule.prepareChartData(coinSymbol, data); if (chartData.length > 0 && chartModule.chart) { chartModule.chart.data.datasets[0].data = chartData; chartModule.chart.data.datasets[0].label = `${coinSymbol} Price (USD)`; if (coinSymbol === 'WOW') { chartModule.chart.options.scales.x.time.unit = 'hour'; } else { const resolution = window.config.chartConfig.resolutions[window.config.currentResolution]; chartModule.chart.options.scales.x.time.unit = resolution && resolution.interval === 'hourly' ? 'hour' : window.config.currentResolution === 'year' ? 'month' : 'day'; } chartModule.chart.update('active'); chartModule.currentCoin = coinSymbol; const loadTime = Date.now() - chartModule.loadStartTime; ui.updateLoadTimeAndCache(loadTime, cachedData); } } catch (error) { console.error(`Error updating chart for ${coinSymbol}:`, error); if (!(chartModule.chart?.data?.datasets[0]?.data?.length > 0)) { if (!chartModule.chart) { chartModule.initChart(); } if (chartModule.chart) { chartModule.chart.data.datasets[0].data = []; chartModule.chart.update('active'); } } } finally { chartModule.hideChartLoader(); } }, showChartLoader: function() { const loader = document.getElementById('chart-loader'); const chart = document.getElementById('coin-chart'); if (!loader || !chart) { return; } loader.classList.remove('hidden'); chart.classList.add('hidden'); }, hideChartLoader: function() { const loader = document.getElementById('chart-loader'); const chart = document.getElementById('coin-chart'); if (!loader || !chart) { return; } loader.classList.add('hidden'); chart.classList.remove('hidden'); }, cleanup: function() { if (this.pendingAnimationFrame) { cancelAnimationFrame(this.pendingAnimationFrame); this.pendingAnimationFrame = null; } if (!document.hidden) { this.currentCoin = null; } this.loadStartTime = 0; this.chartRefs = new WeakMap(); } }; Chart.register(chartModule.verticalLinePlugin); const volumeToggle = { isVisible: localStorage.getItem('volumeToggleState') === 'true', init: function() { const toggleButton = document.getElementById('toggle-volume'); if (toggleButton) { if (typeof CleanupManager !== 'undefined') { CleanupManager.addListener(toggleButton, 'click', volumeToggle.toggle); } else { toggleButton.addEventListener('click', volumeToggle.toggle); } volumeToggle.updateVolumeDisplay(); } }, toggle: function() { volumeToggle.isVisible = !volumeToggle.isVisible; localStorage.setItem('volumeToggleState', volumeToggle.isVisible.toString()); volumeToggle.updateVolumeDisplay(); }, updateVolumeDisplay: function() { const volumeDivs = document.querySelectorAll('[id$="-volume-div"]'); volumeDivs.forEach(div => { if (div) { div.style.display = volumeToggle.isVisible ? 'flex' : 'none'; } }); const toggleButton = document.getElementById('toggle-volume'); if (toggleButton) { updateButtonStyles(toggleButton, volumeToggle.isVisible, 'green'); } }, cleanup: function() { const toggleButton = document.getElementById('toggle-volume'); if (toggleButton) { toggleButton.removeEventListener('click', volumeToggle.toggle); } } }; function updateButtonStyles(button, isActive, color) { button.classList.toggle('text-' + color + '-500', isActive); button.classList.toggle('text-gray-600', !isActive); button.classList.toggle('dark:text-' + color + '-400', isActive); button.classList.toggle('dark:text-gray-400', !isActive); } const app = { btcPriceUSD: 0, autoRefreshInterval: null, nextRefreshTime: null, lastRefreshedTime: null, isRefreshing: false, isAutoRefreshEnabled: localStorage.getItem('autoRefreshEnabled') === 'true', updateNextRefreshTimeRAF: null, refreshTexts: { label: 'Auto-refresh in', disabled: 'Auto-refresh: disabled', justRefreshed: 'Just refreshed', }, cacheTTL: window.config.cacheConfig.ttlSettings.prices, minimumRefreshInterval: 300 * 1000, init: function() { window.addEventListener('load', app.onLoad); app.loadLastRefreshedTime(); app.updateAutoRefreshButton(); NetworkManager.addHandler('offline', () => { ui.showNetworkErrorMessage(); }); NetworkManager.addHandler('reconnected', () => { ui.hideErrorMessage(); app.refreshAllData(); }); NetworkManager.addHandler('maxAttemptsReached', () => { ui.displayErrorMessage( "Server connection lost. Please check your internet connection and try refreshing the page.", 0 ); }); return app; }, onLoad: async function() { ui.showLoader(); try { volumeToggle.init(); await app.updateBTCPrice(); const chartContainer = document.getElementById('coin-chart'); if (chartContainer) { chartModule.initChart(); chartModule.showChartLoader(); } await app.loadAllCoinData(); if (chartModule.chart) { window.config.currentResolution = 'day'; await chartModule.updateChart('BTC'); app.updateResolutionButtons('BTC'); const chartTitle = document.getElementById('chart-title'); if (chartTitle) { chartTitle.textContent = 'Price Chart (BTC)'; } } ui.setActiveContainer('btc-container'); app.setupEventListeners(); app.initAutoRefresh(); } catch (error) { ui.displayErrorMessage('Failed to initialize the dashboard. Please try refreshing the page.'); NetworkManager.handleNetworkError(error); } finally { ui.hideLoader(); if (chartModule.chart) { chartModule.hideChartLoader(); } } }, loadAllCoinData: async function() { try { if (!NetworkManager.isOnline()) { throw new Error('Network is offline'); } const allCoinData = await api.fetchCoinGeckoDataXHR(); if (allCoinData.error) { throw new Error(allCoinData.error); } let volumeData = {}; try { volumeData = await api.fetchVolumeDataXHR(); } catch (volumeError) {} for (const coin of window.config.coins) { const coinData = allCoinData[coin.symbol.toLowerCase()]; if (coinData) { coinData.displayName = coin.displayName || coin.symbol; const backendId = getCoinBackendId ? getCoinBackendId(coin.name) : coin.name; if (volumeData[backendId]) { coinData.total_volume = volumeData[backendId].total_volume; if (!coinData.price_change_percentage_24h && volumeData[backendId].price_change_percentage_24h) { coinData.price_change_percentage_24h = volumeData[backendId].price_change_percentage_24h; } } ui.displayCoinData(coin.symbol, coinData); const cacheKey = `coinData_${coin.symbol}`; CacheManager.set(cacheKey, coinData); } else { console.warn(`No data found for ${coin.symbol}`); } } } catch (error) { console.error('Error loading all coin data:', error); NetworkManager.handleNetworkError(error); ui.displayErrorMessage('Failed to load coin data. Please try refreshing the page.'); } }, loadCoinData: async function(coin) { const cacheKey = `coinData_${coin.symbol}`; let cachedData = CacheManager.get(cacheKey); let data; if (cachedData) { data = cachedData.value; } else { try { ui.showCoinLoader(coin.symbol); if (coin.usesCoinGecko) { data = await api.fetchCoinGeckoDataXHR(coin.symbol); } else { data = await api.fetchCryptoCompareDataXHR(coin.symbol); } if (data.error) { throw new Error(data.error); } CacheManager.set(cacheKey, data, 'prices'); cachedData = null; } catch (error) { NetworkManager.handleNetworkError(error); data = { error: error.message }; } finally { ui.hideCoinLoader(coin.symbol); } } ui.displayCoinData(coin.symbol, data); ui.updateLoadTimeAndCache(0, cachedData); }, setupEventListeners: function() { window.config.coins.forEach(coin => { const container = document.getElementById(`${coin.symbol.toLowerCase()}-container`); if (container) { CleanupManager.addListener(container, 'click', () => { const chartTitle = document.getElementById('chart-title'); if (chartTitle) { chartTitle.textContent = `Price Chart (${coin.symbol})`; } ui.setActiveContainer(`${coin.symbol.toLowerCase()}-container`); if (chartModule.chart) { if (coin.symbol === 'WOW') { window.config.currentResolution = 'day'; } chartModule.updateChart(coin.symbol); app.updateResolutionButtons(coin.symbol); } }); } }); const refreshAllButton = document.getElementById('refresh-all'); if (refreshAllButton) { CleanupManager.addListener(refreshAllButton, 'click', app.refreshAllData); } const headers = document.querySelectorAll('th'); headers.forEach((header, index) => { }); const closeErrorButton = document.getElementById('close-error'); if (closeErrorButton) { CleanupManager.addListener(closeErrorButton, 'click', ui.hideErrorMessage); } const reconnectButton = document.getElementById('network-reconnect'); if (reconnectButton) { CleanupManager.addListener(reconnectButton, 'click', NetworkManager.manualReconnect); } }, initAutoRefresh: function() { const toggleAutoRefreshButton = document.getElementById('toggle-auto-refresh'); if (toggleAutoRefreshButton) { toggleAutoRefreshButton.addEventListener('click', app.toggleAutoRefresh); app.updateAutoRefreshButton(); } if (app.isAutoRefreshEnabled) { app.scheduleNextRefresh(); } }, updateNextRefreshTime: function() { const nextRefreshSpan = document.getElementById('next-refresh-time'); const labelElement = document.getElementById('next-refresh-label'); const valueElement = document.getElementById('next-refresh-value'); if (nextRefreshSpan && labelElement && valueElement) { if (app.isRefreshing) { labelElement.textContent = ''; valueElement.textContent = 'Refreshing...'; valueElement.classList.add('text-blue-500'); return; } else { valueElement.classList.remove('text-blue-500'); } if (app.nextRefreshTime) { if (app.updateNextRefreshTimeRAF) { cancelAnimationFrame(app.updateNextRefreshTimeRAF); app.updateNextRefreshTimeRAF = null; } const updateDisplay = () => { const timeUntilRefresh = Math.max(0, Math.ceil((app.nextRefreshTime - Date.now()) / 1000)); if (timeUntilRefresh === 0) { labelElement.textContent = ''; valueElement.textContent = app.refreshTexts.justRefreshed; } else { const minutes = Math.floor(timeUntilRefresh / 60); const seconds = timeUntilRefresh % 60; labelElement.textContent = `${app.refreshTexts.label}: `; valueElement.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; } if (timeUntilRefresh > 0) { app.updateNextRefreshTimeRAF = requestAnimationFrame(updateDisplay); } }; updateDisplay(); } else { labelElement.textContent = ''; valueElement.textContent = app.refreshTexts.disabled; } } }, scheduleNextRefresh: function() { if (app.autoRefreshInterval) { clearTimeout(app.autoRefreshInterval); } const now = Date.now(); let earliestExpiration = Infinity; Object.keys(localStorage).forEach(key => { if (key.startsWith('coinData_') || key.startsWith('chartData_') || key === 'coinGeckoOneLiner') { try { const cachedItem = JSON.parse(localStorage.getItem(key)); if (cachedItem && cachedItem.expiresAt) { earliestExpiration = Math.min(earliestExpiration, cachedItem.expiresAt); } } catch (error) { localStorage.removeItem(key); } } }); let nextRefreshTime = now + app.minimumRefreshInterval; if (earliestExpiration !== Infinity) { nextRefreshTime = Math.max(earliestExpiration, now + app.minimumRefreshInterval); } else { nextRefreshTime = now + window.config.cacheTTL; } const timeUntilRefresh = nextRefreshTime - now; app.nextRefreshTime = nextRefreshTime; app.autoRefreshInterval = setTimeout(() => { if (NetworkManager.isOnline()) { app.refreshAllData(); } else { app.scheduleNextRefresh(); } }, timeUntilRefresh); localStorage.setItem('nextRefreshTime', app.nextRefreshTime.toString()); app.updateNextRefreshTime(); }, refreshAllData: async function() { //console.log('Price refresh started at', new Date().toLocaleTimeString()); if (app.isRefreshing) { console.log('Refresh already in progress, skipping...'); return; } if (!NetworkManager.isOnline()) { ui.displayErrorMessage("Network connection unavailable. Please check your connection."); return; } const lastGeckoRequest = rateLimiter.lastRequestTime['coingecko'] || 0; const timeSinceLastRequest = Date.now() - lastGeckoRequest; const waitTime = Math.max(0, rateLimiter.minRequestInterval.coingecko - timeSinceLastRequest); if (waitTime > 0) { const seconds = Math.ceil(waitTime / 1000); ui.displayErrorMessage(`Rate limit: Please wait ${seconds} seconds before refreshing`); let remainingTime = seconds; const countdownInterval = setInterval(() => { remainingTime--; if (remainingTime > 0) { ui.displayErrorMessage(`Rate limit: Please wait ${remainingTime} seconds before refreshing`); } else { clearInterval(countdownInterval); ui.hideErrorMessage(); } }, 1000); return; } //console.log('Starting refresh of all data...'); app.isRefreshing = true; app.updateNextRefreshTime(); ui.showLoader(); chartModule.showChartLoader(); try { ui.hideErrorMessage(); CacheManager.clear(); const btcUpdateSuccess = await app.updateBTCPrice(); if (!btcUpdateSuccess) { console.warn('BTC price update failed, continuing with cached or default value'); } await new Promise(resolve => setTimeout(resolve, 1000)); const allCoinData = await api.fetchCoinGeckoDataXHR(); if (allCoinData.error) { throw new Error(`CoinGecko API Error: ${allCoinData.error}`); } let volumeData = {}; try { volumeData = await api.fetchVolumeDataXHR(); } catch (volumeError) {} const failedCoins = []; for (const coin of window.config.coins) { const symbol = coin.symbol.toLowerCase(); const coinData = allCoinData[symbol]; try { if (!coinData) { throw new Error(`No data received`); } coinData.displayName = coin.displayName || coin.symbol; const backendId = getCoinBackendId ? getCoinBackendId(coin.name) : coin.name; if (volumeData[backendId]) { coinData.total_volume = volumeData[backendId].total_volume; if (!coinData.price_change_percentage_24h && volumeData[backendId].price_change_percentage_24h) { coinData.price_change_percentage_24h = volumeData[backendId].price_change_percentage_24h; } } else { try { const cacheKey = `coinData_${coin.symbol}`; const cachedData = CacheManager.get(cacheKey); if (cachedData && cachedData.value && cachedData.value.total_volume) { coinData.total_volume = cachedData.value.total_volume; } if (cachedData && cachedData.value && cachedData.value.price_change_percentage_24h && !coinData.price_change_percentage_24h) { coinData.price_change_percentage_24h = cachedData.value.price_change_percentage_24h; } } catch (e) { console.warn(`Failed to retrieve cached volume data for ${coin.symbol}:`, e); } } ui.displayCoinData(coin.symbol, coinData); const cacheKey = `coinData_${coin.symbol}`; CacheManager.set(cacheKey, coinData, 'prices'); //console.log(`Updated price for ${coin.symbol}: $${coinData.current_price}`); } catch (coinError) { console.warn(`Failed to update ${coin.symbol}: ${coinError.message}`); failedCoins.push(coin.symbol); } } await new Promise(resolve => setTimeout(resolve, 1000)); if (chartModule.currentCoin) { try { await chartModule.updateChart(chartModule.currentCoin, true); } catch (chartError) { console.error('Chart update failed:', chartError); } } app.lastRefreshedTime = new Date(); localStorage.setItem('lastRefreshedTime', app.lastRefreshedTime.getTime().toString()); ui.updateLastRefreshedTime(); if (failedCoins.length > 0) { const failureMessage = failedCoins.length === window.config.coins.length ? 'Failed to update any coin data' : `Failed to update some coins: ${failedCoins.join(', ')}`; let countdown = 5; ui.displayErrorMessage(`${failureMessage} (${countdown}s)`); const countdownInterval = setInterval(() => { countdown--; if (countdown > 0) { ui.displayErrorMessage(`${failureMessage} (${countdown}s)`); } else { clearInterval(countdownInterval); ui.hideErrorMessage(); } }, 1000); } //console.log(`Price refresh completed at ${new Date().toLocaleTimeString()}. Updated ${window.config.coins.length - failedCoins.length}/${window.config.coins.length} coins.`); } catch (error) { console.error('Critical error during refresh:', error); NetworkManager.handleNetworkError(error); let countdown = 10; ui.displayErrorMessage(`Refresh failed: ${error.message}. Please try again later. (${countdown}s)`); const countdownInterval = setInterval(() => { countdown--; if (countdown > 0) { ui.displayErrorMessage(`Refresh failed: ${error.message}. Please try again later. (${countdown}s)`); } else { clearInterval(countdownInterval); ui.hideErrorMessage(); } }, 1000); console.error(`Price refresh failed at ${new Date().toLocaleTimeString()}: ${error.message}`); } finally { ui.hideLoader(); chartModule.hideChartLoader(); app.isRefreshing = false; app.updateNextRefreshTime(); if (app.isAutoRefreshEnabled) { app.scheduleNextRefresh(); } //console.log(`Refresh process finished at ${new Date().toLocaleTimeString()}, next refresh scheduled: ${app.isAutoRefreshEnabled ? 'yes' : 'no'}`); } }, updateAutoRefreshButton: function() { const button = document.getElementById('toggle-auto-refresh'); if (button) { if (app.isAutoRefreshEnabled) { button.classList.remove('text-gray-600', 'dark:text-gray-400'); button.classList.add('text-green-500', 'dark:text-green-400'); app.startSpinAnimation(); } else { button.classList.remove('text-green-500', 'dark:text-green-400'); button.classList.add('text-gray-600', 'dark:text-gray-400'); app.stopSpinAnimation(); } button.title = app.isAutoRefreshEnabled ? 'Disable Auto-Refresh' : 'Enable Auto-Refresh'; } }, startSpinAnimation: function() { const svg = document.querySelector('#toggle-auto-refresh svg'); if (svg) { svg.classList.add('animate-spin'); setTimeout(() => { svg.classList.remove('animate-spin'); }, 2000); } }, stopSpinAnimation: function() { const svg = document.querySelector('#toggle-auto-refresh svg'); if (svg) { svg.classList.remove('animate-spin'); } }, updateLastRefreshedTime: function() { const lastRefreshedElement = document.getElementById('last-refreshed-time'); if (lastRefreshedElement && app.lastRefreshedTime) { const formattedTime = app.lastRefreshedTime.toLocaleTimeString(); lastRefreshedElement.textContent = `Last Refreshed: ${formattedTime}`; } }, loadLastRefreshedTime: function() { const storedTime = localStorage.getItem('lastRefreshedTime'); if (storedTime) { app.lastRefreshedTime = new Date(parseInt(storedTime)); ui.updateLastRefreshedTime(); } }, updateBTCPrice: async function() { try { const priceData = await window.PriceManager.getPrices(); if (priceData) { if (priceData.bitcoin && priceData.bitcoin.usd) { app.btcPriceUSD = priceData.bitcoin.usd; return true; } else if (priceData.btc && priceData.btc.usd) { app.btcPriceUSD = priceData.btc.usd; return true; } } if (app.btcPriceUSD > 0) { console.log('Using previously cached BTC price:', app.btcPriceUSD); return true; } console.warn('Could not find BTC price in current data'); return false; } catch (error) { console.error('Error fetching BTC price:', error); if (app.btcPriceUSD > 0) { console.log('Using previously cached BTC price after error:', app.btcPriceUSD); return true; } return false; } }, updateResolutionButtons: function(coinSymbol) { const resolutionButtons = document.querySelectorAll('.resolution-button'); resolutionButtons.forEach(button => { const resolution = button.id.split('-')[1]; if (coinSymbol === 'WOW') { if (resolution === 'day') { button.classList.remove('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); button.classList.add('active'); button.disabled = false; } else { button.classList.add('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); button.classList.remove('active'); button.disabled = true; } } else { button.classList.remove('text-gray-400', 'cursor-not-allowed', 'opacity-50', 'outline-none'); button.classList.toggle('active', resolution === window.config.currentResolution); button.disabled = false; } }); }, toggleAutoRefresh: function() { app.isAutoRefreshEnabled = !app.isAutoRefreshEnabled; localStorage.setItem('autoRefreshEnabled', app.isAutoRefreshEnabled.toString()); if (app.isAutoRefreshEnabled) { app.scheduleNextRefresh(); } else { if (app.autoRefreshInterval) { clearTimeout(app.autoRefreshInterval); app.autoRefreshInterval = null; } app.nextRefreshTime = null; localStorage.removeItem('nextRefreshTime'); } app.updateAutoRefreshButton(); app.updateNextRefreshTime(); } }; const resolutionButtons = document.querySelectorAll('.resolution-button'); resolutionButtons.forEach(button => { button.addEventListener('click', () => { const resolution = button.id.split('-')[1]; const currentCoin = chartModule.currentCoin; if (currentCoin !== 'WOW' || resolution === 'day') { window.config.currentResolution = resolution; chartModule.updateChart(currentCoin, true); app.updateResolutionButtons(currentCoin); } }); }); function cleanup() { console.log('Starting cleanup process'); try { if (window.MemoryManager) { MemoryManager.forceCleanup(); } if (chartModule) { CleanupManager.registerResource('chartModule', chartModule, (cm) => { cm.cleanup(); }); } if (volumeToggle) { CleanupManager.registerResource('volumeToggle', volumeToggle, (vt) => { vt.cleanup(); }); } ['chartModule', 'volumeToggle', 'app'].forEach(ref => { if (window[ref]) { window[ref] = null; } }); const cleanupCounts = CleanupManager.clearAll(); console.log('All resources cleaned up:', cleanupCounts); } catch (error) { console.error('Error during cleanup:', error); CleanupManager.clearAll(); } } window.cleanup = cleanup; const appCleanup = { init: function() { window.addEventListener('beforeunload', this.globalCleanup); }, globalCleanup: function() { try { if (window.MemoryManager) { MemoryManager.forceCleanup(); } if (app.autoRefreshInterval) { CleanupManager.clearTimeout(app.autoRefreshInterval); } if (chartModule) { CleanupManager.registerResource('chartModule', chartModule, (cm) => { cm.cleanup(); }); } if (volumeToggle) { CleanupManager.registerResource('volumeToggle', volumeToggle, (vt) => { vt.cleanup(); }); } CleanupManager.clearAll(); CacheManager.clear(); } catch (error) {} }, manualCleanup: function() { this.globalCleanup(); window.location.reload(); } }; document.addEventListener('DOMContentLoaded', () => { if (window.NetworkManager && !window.networkManagerInitialized) { NetworkManager.initialize({ connectionTestEndpoint: '/json', connectionTestTimeout: 3000, reconnectDelay: 5000, maxReconnectAttempts: 5 }); window.networkManagerInitialized = true; } app.init(); if (window.MemoryManager) { if (typeof MemoryManager.enableAutoCleanup === 'function') { MemoryManager.enableAutoCleanup(); } else { MemoryManager.initialize({ autoCleanup: true, debug: false }); } } CleanupManager.setInterval(() => { CacheManager.cleanup(); }, 300000); CleanupManager.setInterval(() => { if (chartModule && chartModule.currentCoin && NetworkManager.isOnline()) { chartModule.updateChart(chartModule.currentCoin); } }, 900000); CleanupManager.addListener(document, 'visibilitychange', () => { if (!document.hidden) { console.log('Page is now visible'); if (NetworkManager.isOnline()) { if (chartModule && chartModule.currentCoin) { chartModule.updateChart(chartModule.currentCoin); } } else { NetworkManager.attemptReconnect(); } } }); CleanupManager.addListener(window, 'beforeunload', () => { cleanup(); }); appCleanup.init(); }); app.init = function() { window.addEventListener('load', app.onLoad); app.loadLastRefreshedTime(); app.updateAutoRefreshButton(); if (window.NetworkManager) { NetworkManager.addHandler('offline', () => { ui.showNetworkErrorMessage(); }); NetworkManager.addHandler('reconnected', () => { ui.hideErrorMessage(); app.refreshAllData(); }); NetworkManager.addHandler('maxAttemptsReached', () => { ui.displayErrorMessage( "Server connection lost. Please check your internet connection and try refreshing the page.", 0 ); }); } return app; }; app.init();