/** * Pricify - Auto-Refresh & Data Loading Module * Polls /api/proxy.php for live price updates, loads chart data, * and provides debounced asset search with dropdown rendering. * Vanilla JS, no frameworks, no module exports. */ /* ============================================================ Configuration ============================================================ */ const PROXY_URL = '/api/proxy.php'; const DEFAULT_REFRESH_INTERVAL = 30000; // 30 seconds const SEARCH_DEBOUNCE_MS = 300; /* ============================================================ startAutoRefresh — periodic price-data refresh ------------------------------------------------------------ section : 'crypto' | 'stocks' | 'indices' — maps to proxy action intervalMs: polling interval in ms (default 30000) ============================================================ */ /** * Start an interval-based auto-refresh that fetches price data * from the API proxy and updates table rows identified by * data-symbol attributes. Cells with .price-cell and * .change-cell get updated. A brief flash animation is applied * to indicate fresh data. * Returns the interval id (for clearInterval later). */ function startAutoRefresh(section, intervalMs) { try { const ms = intervalMs || DEFAULT_REFRESH_INTERVAL; // Map section to proxy action const actionMap = { crypto: 'crypto-top', stocks: 'stocks-list', indices: 'indices', }; const action = actionMap[section] || 'crypto-top'; // Immediate first fetch fetchAndUpdatePrices(action, section); // Periodic refresh const intervalId = setInterval(function () { fetchAndUpdatePrices(action, section); }, ms); return intervalId; } catch (err) { console.error('[refresh] startAutoRefresh error:', err); return null; } } /* ============================================================ fetchAndUpdatePrices — internal: fetch + DOM update ============================================================ */ /** * Fetch price data from proxy and update visible DOM elements * (price cells, change indicators). Called both on init and * on each refresh tick. */ async function fetchAndUpdatePrices(action, section) { try { const url = PROXY_URL + '?action=' + encodeURIComponent(action); const response = await fetch(url); if (!response.ok) { console.warn('[refresh] HTTP error:', response.status); return; } const result = await response.json(); if (!result.success || !Array.isArray(result.data)) { console.warn('[refresh] API returned no data'); return; } // Update each asset row in the DOM result.data.forEach(function (asset) { updateAssetRow(asset, section); }); } catch (err) { console.error('[refresh] fetchAndUpdatePrices error:', err); } } /* ============================================================ updateAssetRow — update a single table row ============================================================ */ /** * Update elements that have data-symbol matching the asset. * Updates price cells (.price-cell), change cells (.change-cell), * and appends a brief CSS animation class for user feedback. */ function updateAssetRow(asset, section) { try { const symbol = asset.symbol || asset.ticker || ''; if (!symbol) return; const rows = document.querySelectorAll( 'tr[data-symbol="' + CSS.escape(symbol) + '"]' ); if (!rows.length) return; const price = parseFloat(asset.price || asset.nav || 0); const change = parseFloat(asset.change || asset.change_pct || 0); const changePct = parseFloat(asset.change_pct || asset.change || 0); rows.forEach(function (row) { // Update price cell const priceCell = row.querySelector('.price-cell'); if (priceCell) { priceCell.textContent = formatPrice(price); priceCell.className = 'price-cell ' + changeClass(change); } // Update change cell const changeCell = row.querySelector('.change-cell'); if (changeCell) { const sign = change >= 0 ? '+' : ''; changeCell.textContent = sign + changePct.toFixed(2) + '%'; changeCell.className = 'change-cell ' + changeClass(change); } // Brief flash animation to signal update row.classList.remove('flash-update'); // Force reflow so the animation re-triggers void row.offsetWidth; row.classList.add('flash-update'); setTimeout(function () { row.classList.remove('flash-update'); }, 800); }); } catch (err) { console.error('[refresh] updateAssetRow error:', err); } } /* ============================================================ loadChartData — fetch chart data and render via Chart.js ------------------------------------------------------------ symbol : Asset ticker / symbol (e.g. 'BTC', 'RELIANCE') range : Time range string (e.g. '1d', '1w', '1m', '1y') canvasId : element id to render into ============================================================ */ /** * Fetch historical price data for a given symbol from the API * proxy and, if Chart.js is loaded, render it on the supplied * canvas. The function expects the API to return an object * with a `prices` (array of numbers) and `labels` (array of * date/ time strings). * * If an existing Chart instance is stored on the canvas element * (as `canvas.__chart`), it is updated in place rather than * destroyed, preserving smooth transitions. */ async function loadChartData(symbol, range, canvasId) { try { if (!symbol || !canvasId) return; const canvas = document.getElementById(canvasId); if (!canvas) { console.warn('[refresh] loadChartData — canvas not found:', canvasId); return; } // Determine the correct proxy action based on canvas data-section or default const section = canvas.dataset.section || 'crypto'; const actionMap = { crypto: 'crypto-chart', stocks: 'stocks-chart', indices: 'indices', }; const action = actionMap[section] || 'crypto-chart'; const url = PROXY_URL + '?action=' + encodeURIComponent(action) + '&symbol=' + encodeURIComponent(symbol) + '&range=' + encodeURIComponent(range || '1m'); const response = await fetch(url); if (!response.ok) { console.warn('[refresh] Chart data HTTP error:', response.status); return; } const result = await response.json(); if (!result.success) { console.warn('[refresh] Chart data API error:', result.error); return; } const labels = result.labels || []; const prices = result.prices || []; const color = canvas.dataset.color || '#2962ff'; // Try to render via Chart.js if available if (typeof Chart !== 'undefined') { // Update in-place if chart already exists on the canvas if (canvas.__chart && typeof canvas.__chart.update === 'function') { canvas.__chart.data.labels = labels; canvas.__chart.data.datasets[0].data = prices; canvas.__chart.update('none'); // no animation on refresh } else { canvas.__chart = initPriceChart(canvasId, labels, prices, color); } } } catch (err) { console.error('[refresh] loadChartData error:', err); } } /* ============================================================ searchAssets — debounced asset search with dropdown ------------------------------------------------------------ query : Search input string callback : Optional callback(result) after successful fetch ============================================================ */ let _searchTimer = null; let _lastSearchQuery = ''; /** * Perform a debounced asset search via the API proxy. * Renders matching results into a dropdown container * (#search-results). Clears the dropdown if query is too short. * * A 300 ms debounce prevents API thundering on every keystroke. */ function searchAssets(query) { try { // Clear previous timer if (_searchTimer) { clearTimeout(_searchTimer); _searchTimer = null; } const trimmed = (query || '').trim(); // Short query — clear results immediately if (trimmed.length < 1) { clearSearchResults(); _lastSearchQuery = ''; return; } // Debounce _searchTimer = setTimeout(async function () { try { // Avoid duplicate requests for the same query if (trimmed === _lastSearchQuery) return; _lastSearchQuery = trimmed; const url = PROXY_URL + '?action=mf-search&q=' + encodeURIComponent(trimmed); const response = await fetch(url); if (!response.ok) { console.warn('[refresh] Search HTTP error:', response.status); return; } const result = await response.json(); if (!result.success) { console.warn('[refresh] Search API error:', result.error); return; } renderSearchResults(result.data || []); } catch (err) { console.error('[refresh] Search fetch error:', err); } }, SEARCH_DEBOUNCE_MS); } catch (err) { console.error('[refresh] searchAssets error:', err); } } /* ============================================================ renderSearchResults — populate search dropdown ============================================================ */ /** * Render an array of search result objects into the dropdown * container (#search-results). Each result gets a clickable * row. If results are empty, the dropdown shows a "no results" * message. */ function renderSearchResults(results) { try { const container = document.getElementById('search-results'); if (!container) return; if (!Array.isArray(results) || results.length === 0) { container.innerHTML = '
No assets found
'; container.style.display = 'block'; return; } let html = ''; results.forEach(function (item) { const name = item.name || item.scheme_name || 'Unknown'; const symbol = item.symbol || item.ticker || ''; const type = item.type || 'fund'; const href = '/detail.php?symbol=' + encodeURIComponent(symbol) + '&type=' + encodeURIComponent(type); html += '' + '' + escapeHtml(name) + '' + (symbol ? '' + escapeHtml(symbol) + '' : '') + ''; }); container.innerHTML = html; container.style.display = 'block'; } catch (err) { console.error('[refresh] renderSearchResults error:', err); } } /* ============================================================ clearSearchResults — hide / empty the search dropdown ============================================================ */ /** * Clear the search results dropdown and hide it. */ function clearSearchResults() { try { const container = document.getElementById('search-results'); if (container) { container.innerHTML = ''; container.style.display = 'none'; } } catch (err) { console.error('[refresh] clearSearchResults error:', err); } } /* ============================================================ Utility helpers (inline for independence) ============================================================ */ /** * Format a number for price display (commas, 2 decimals). */ function formatPrice(value) { try { if (value === null || value === undefined || isNaN(value)) return '—'; if (Math.abs(value) < 0.01) { return value.toFixed(6); } return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); } catch (_) { return String(value); } } /** * Return the appropriate CSS class for a price change value. */ function changeClass(value) { if (value > 0) return 'price-up'; if (value < 0) return 'price-down'; return 'price-neutral'; } /** * Minimal HTML-entity escaping for safe DOM insertion. */ function escapeHtml(str) { if (typeof str !== 'string') return ''; const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return str.replace(/[&<>"']/g, function (ch) { return map[ch] || ch; }); } /* ============================================================ CSS injection for flash-update animation ============================================================ */ (function injectFlashAnimation() { try { if (document.getElementById('mp-flash-style')) return; const style = document.createElement('style'); style.id = 'mp-flash-style'; style.textContent = '@keyframes mp-flash { ' + '0%,100% { background-color: transparent; } ' + '50% { background-color: rgba(212,175,55,0.1); } ' + '} ' + 'tr.flash-update { animation: mp-flash 0.8s ease-out; }'; document.head.appendChild(style); } catch (_) { // Non-critical — flash animation simply won't appear } })(); /* ============================================================ DOMContentLoaded — wire up search input handlers ============================================================ */ document.addEventListener('DOMContentLoaded', function () { try { // Connect search input to debounced search const searchInput = document.querySelector('.search-box'); if (searchInput) { searchInput.addEventListener('input', function (e) { searchAssets(e.target.value); }); // Close dropdown on blur (with small delay for click capture) searchInput.addEventListener('blur', function () { setTimeout(clearSearchResults, 200); }); // Re-open on focus if there's text searchInput.addEventListener('focus', function (e) { if (e.target.value.trim().length > 0) { searchAssets(e.target.value); } }); } // Close search results on Escape document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { clearSearchResults(); } }); } catch (err) { console.error('[refresh] DOM init error:', err); } });