// Pokemon Stock Monitor Dashboard - Frontend JavaScript const API_BASE = '/api'; // State let currentPage = 'dashboard'; let charts = {}; let currentEventFilter = null; // { type: 'new_drop'|'restock', period: 'today'|'week' } let currentProductPage = 1; const PRODUCTS_PER_PAGE = 50; // Initialize document.addEventListener('DOMContentLoaded', () => { initNavigation(); loadDashboard(); loadSuggestions(); initEventListeners(); // Auto-refresh dashboard every 30 seconds setInterval(() => { if (currentPage === 'dashboard') { loadDashboard(); } }, 30000); }); // Navigation function initNavigation() { document.querySelectorAll('.nav-links a').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const page = e.target.dataset.page; navigateTo(page); }); }); } function navigateTo(page) { // Update active nav document.querySelectorAll('.nav-links a').forEach(a => a.classList.remove('active')); document.querySelector(`[data-page="${page}"]`).classList.add('active'); // Show page document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.getElementById(`page-${page}`).classList.add('active'); currentPage = page; // Load page content switch (page) { case 'dashboard': loadDashboard(); break; case 'products': loadProducts(); break; case 'news': loadNews(); break; case 'analytics': loadAnalytics(); break; case 'favorites': loadFavorites(); break; case 'control': loadControlPanel(); break; case 'users': loadUsers(); break; case 'settings': loadSettings(); break; } } // Event Listeners function initEventListeners() { // Product filters - clear event filter when user changes regular filters ['filterSite', 'filterCategory', 'filterStock', 'filterFavorites'].forEach(id => { const el = document.getElementById(id); if (el) { el.addEventListener('change', () => { currentEventFilter = null; // Clear event filter when using regular filters currentProductPage = 1; loadProducts(); }); } }); // Clear filters button document.getElementById('clearFiltersBtn')?.addEventListener('click', clearAllFilters); // Add favorite button document.getElementById('addFavoriteBtn')?.addEventListener('click', addFavorite); // Modal close document.querySelector('.modal-close')?.addEventListener('click', closeModal); document.getElementById('productModal')?.addEventListener('click', (e) => { if (e.target.id === 'productModal') closeModal(); }); } // Clear all product filters function clearAllFilters() { document.getElementById('filterSite').value = ''; document.getElementById('filterCategory').value = ''; document.getElementById('filterStock').value = ''; document.getElementById('filterFavorites').checked = false; currentEventFilter = null; currentProductPage = 1; loadProducts(); } // API Helpers async function api(endpoint, options = {}) { try { const response = await fetch(`${API_BASE}${endpoint}`, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers } }); return await response.json(); } catch (error) { console.error('API Error:', error); return null; } } // Store display names and branding const STORE_INFO = { pokemoncenter: { name: 'Pokemon Center', logo: 'https://www.google.com/s2/favicons?domain=pokemoncenter.com&sz=128', fallback: 'PC', color: '#ffcb05', bgColor: '#1d1d42' }, target: { name: 'Target', logo: 'https://www.google.com/s2/favicons?domain=target.com&sz=128', fallback: '◎', color: '#cc0000', bgColor: '#ffffff' }, walmart: { name: 'Walmart', logo: 'https://www.google.com/s2/favicons?domain=walmart.com&sz=128', fallback: '✱', color: '#0071ce', bgColor: '#ffffff' }, bestbuy: { name: 'Best Buy', logo: 'https://www.google.com/s2/favicons?domain=bestbuy.com&sz=128', fallback: 'BB', color: '#fff200', bgColor: '#0046be' }, gamestop: { name: 'GameStop', logo: 'https://www.google.com/s2/favicons?domain=gamestop.com&sz=128', fallback: 'GS', color: '#ff0000', bgColor: '#000000' } }; // All stores to show (even without data) const ALL_STORES = ['pokemoncenter', 'target', 'gamestop', 'walmart', 'bestbuy']; // Dashboard async function loadDashboard() { const stats = await api('/stats'); if (!stats) return; // Update stats cards document.getElementById('newDropsToday').textContent = stats.new_drops_today || 0; document.getElementById('restocksToday').textContent = stats.restocks_today || 0; document.getElementById('newDropsWeek').textContent = stats.new_drops_week || 0; document.getElementById('restocksWeek').textContent = stats.restocks_week || 0; // Update store cards loadStoreCards(stats.sites || []); // Update status const statusDot = document.getElementById('monitorStatus'); const statusText = document.getElementById('statusText'); if (stats.last_check) { statusDot.classList.add('active'); const lastCheck = new Date(stats.last_check.checked_at); statusText.textContent = `Last check: ${formatTime(lastCheck)}`; } else { statusDot.classList.remove('active'); statusText.textContent = 'No checks yet'; } // Load activity feed loadActivityFeed(); } function loadStoreCards(sites) { const container = document.getElementById('storeCards'); // Create a map of site data for quick lookup const siteDataMap = {}; (sites || []).forEach(s => { siteDataMap[s.site] = s; }); // Show all stores, even without data container.innerHTML = ALL_STORES.map(storeKey => { const info = STORE_INFO[storeKey]; const data = siteDataMap[storeKey] || { total_products: 0, in_stock_count: 0 }; const outOfStock = data.total_products - data.in_stock_count; const hasData = data.total_products > 0; return `
${info.name}
${info.name}
${hasData ? ` ${data.in_stock_count} in stock ${outOfStock} sold out ` : ` Not tracking yet `}
`; }).join(''); } function goToStoreProducts(store) { // Navigate to products page and filter by store navigateTo('products'); document.getElementById('filterSite').value = store; loadProducts(); } async function loadActivityFeed() { const data = await api('/events?limit=20'); if (!data || !data.events) return; const feed = document.getElementById('activityFeed'); if (data.events.length === 0) { feed.innerHTML = '
No recent activity
'; return; } feed.innerHTML = data.events.map(event => { const storeInfo = STORE_INFO[event.site] || { name: event.site, color: '#333', bgColor: '#666', logo: '', fallback: '?' }; const badgeClass = event.event_type === 'restock' ? 'restock' : 'new-drop'; const badgeText = event.event_type === 'restock' ? 'Restock' : 'New'; const hasProductImage = event.image_url && !event.image_url.includes('data:') && event.image_url.length > 10; // Build store logo HTML with fallback const storeLogoHtml = `
`; return `
${hasProductImage ? `` : storeLogoHtml }
${storeInfo.name} • ${event.current_price || 'N/A'}
${formatTime(new Date(event.recorded_at))}
`; }).join(''); } function handleActivityImageError(img, site) { const storeInfo = STORE_INFO[site] || { name: site, color: '#333', bgColor: '#666', logo: '', fallback: '?' }; const div = document.createElement('div'); div.className = 'activity-store-icon'; div.style.background = storeInfo.bgColor; div.innerHTML = ` `; img.replaceWith(div); } // Navigate to products filtered by event type function goToFilteredProducts(eventType, period) { currentEventFilter = { type: eventType, period: period }; currentProductPage = 1; navigateTo('products'); } // Clear event filter function clearEventFilter() { currentEventFilter = null; currentProductPage = 1; loadProducts(); } // Products async function loadProducts() { const site = document.getElementById('filterSite')?.value || ''; const category = document.getElementById('filterCategory')?.value || ''; const inStock = document.getElementById('filterStock')?.value || ''; const favoritesOnly = document.getElementById('filterFavorites')?.checked || false; const offset = (currentProductPage - 1) * PRODUCTS_PER_PAGE; let url = `/products?limit=${PRODUCTS_PER_PAGE}&offset=${offset}`; if (site) url += `&site=${site}`; if (category) url += `&category=${encodeURIComponent(category)}`; if (inStock) url += `&in_stock=${inStock}`; if (favoritesOnly) url += '&favorites_only=true'; // Apply event type filter if set if (currentEventFilter) { url += `&event_type=${currentEventFilter.type}&period=${currentEventFilter.period}`; } // Update filter badge visibility updateFilterBadge(); const data = await api(url); if (!data || !data.products) return; const grid = document.getElementById('productsGrid'); if (data.products.length === 0) { grid.innerHTML = '
No products found
'; renderPagination(0); return; } grid.innerHTML = data.products.map(product => { const storeInfo = STORE_INFO[product.site] || { name: product.site, color: '#333', bgColor: '#666', logo: '', fallback: '?' }; const cleanName = cleanProductName(product.name); const hasImage = product.image_url && !product.image_url.includes('data:') && product.image_url.length > 10; return `
${hasImage ? `` : `
` }
${cleanName}
${product.current_price || 'N/A'} ${product.in_stock ? 'IN STOCK' : 'OUT OF STOCK'}
${storeInfo.name}
`; }).join(''); renderPagination(data.total || 0); } function renderPagination(total) { const container = document.getElementById('productsPagination'); if (!container) return; const totalPages = Math.ceil(total / PRODUCTS_PER_PAGE); if (totalPages <= 1) { container.innerHTML = ''; return; } const start = (currentProductPage - 1) * PRODUCTS_PER_PAGE + 1; const end = Math.min(currentProductPage * PRODUCTS_PER_PAGE, total); container.innerHTML = ` `; } function changeProductPage(page) { currentProductPage = page; loadProducts(); document.getElementById('page-products').scrollIntoView({ behavior: 'smooth' }); } // Handle product image errors by showing store icon function handleProductImageError(img, site) { const storeInfo = STORE_INFO[site] || { name: site, color: '#333', bgColor: '#666', logo: '', fallback: '?' }; const container = img.parentElement; img.style.display = 'none'; // Create fallback element const fallback = document.createElement('div'); fallback.className = 'product-store-icon'; fallback.style.background = storeInfo.bgColor; fallback.innerHTML = ` `; container.insertBefore(fallback, img); } async function showProductDetails(productId) { const product = await api(`/products/${productId}`); const history = await api(`/products/${productId}/history`); if (!product) return; document.getElementById('modalProductName').textContent = cleanProductName(product.name); const storeInfo = STORE_INFO[product.site] || { name: product.site, color: '#333', bgColor: '#666', icon: '?' }; document.getElementById('modalProductDetails').innerHTML = `

Store: ${storeInfo.name}

Category: ${product.category || 'Unknown'}

Price: ${product.current_price || 'N/A'}

Status: ${product.in_stock ? 'In Stock' : 'Out of Stock'}

First Seen: ${formatDate(product.first_seen)}

Last Seen: ${formatDate(product.last_seen)}

View on ${storeInfo.name} →

${history && history.price_history && history.price_history.length > 0 ? `

Price History

` : ''} `; document.getElementById('productModal').classList.add('show'); } function closeModal() { document.getElementById('productModal').classList.remove('show'); } // Analytics async function loadAnalytics() { await Promise.all([ loadDropTimingChart(), loadStockDurationChart(), loadSiteChart() ]); } async function loadDropTimingChart() { const data = await api('/analytics/drops'); if (!data || !data.drop_timing) return; const ctx = document.getElementById('dropTimingChart'); if (!ctx) return; // Destroy existing chart if (charts.dropTiming) charts.dropTiming.destroy(); // Fill in missing hours const hourlyData = new Array(24).fill(0); data.drop_timing.forEach(d => { hourlyData[parseInt(d.hour)] = d.count; }); charts.dropTiming = new Chart(ctx, { type: 'bar', data: { labels: Array.from({length: 24}, (_, i) => `${i}:00`), datasets: [{ label: 'Drops/Restocks', data: hourlyData, backgroundColor: 'rgba(255, 203, 5, 0.6)', borderColor: 'rgba(255, 203, 5, 1)', borderWidth: 1 }] }, options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { color: '#aaa' } }, x: { ticks: { color: '#aaa' } } } } }); } async function loadStockDurationChart() { const data = await api('/analytics/stock'); if (!data || !data.stock_duration) return; const ctx = document.getElementById('stockDurationChart'); if (!ctx) return; if (charts.stockDuration) charts.stockDuration.destroy(); const labels = data.stock_duration.map(d => d.category || 'Unknown'); const values = data.stock_duration.map(d => d.avg_minutes_in_stock || 0); charts.stockDuration = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Avg Minutes In Stock', data: values, backgroundColor: 'rgba(0, 200, 83, 0.6)', borderColor: 'rgba(0, 200, 83, 1)', borderWidth: 1 }] }, options: { responsive: true, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true, ticks: { color: '#aaa' } }, y: { ticks: { color: '#aaa' } } } } }); } async function loadSiteChart() { const data = await api('/analytics/sites'); if (!data || !data.sites) return; const ctx = document.getElementById('siteChart'); if (!ctx) return; if (charts.site) charts.site.destroy(); charts.site = new Chart(ctx, { type: 'doughnut', data: { labels: data.sites.map(s => s.site), datasets: [{ data: data.sites.map(s => s.total_products), backgroundColor: [ 'rgba(255, 203, 5, 0.8)', 'rgba(0, 153, 255, 0.8)', 'rgba(0, 200, 83, 0.8)', 'rgba(198, 40, 40, 0.8)' ] }] }, options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: '#eee' } } } } }); } // Favorites async function loadFavorites() { const data = await api('/favorites'); if (!data || !data.favorites) return; const list = document.getElementById('favoritesList'); if (data.favorites.length === 0) { list.innerHTML = '
No favorites yet. Add some above!
'; return; } list.innerHTML = data.favorites.map(fav => `
${fav.type === 'category' ? '📁' : '🔗'}
${escapeHtml(fav.display_name || fav.value)}
${fav.type === 'category' ? 'Category' : 'Product'}
${fav.priority.toUpperCase()}
`).join(''); } async function loadSuggestions() { const data = await api('/favorites/suggestions'); if (!data || !data.suggestions) return; const container = document.getElementById('suggestions'); container.innerHTML = data.suggestions.slice(0, 10).map(s => ` ${s} `).join(''); } async function addFavorite() { const type = document.getElementById('favType').value; const value = document.getElementById('favValue').value.trim(); const priority = document.getElementById('favPriority').value; if (!value) { alert('Please enter a value'); return; } await api('/favorites', { method: 'POST', body: JSON.stringify({ type, value, priority }) }); document.getElementById('favValue').value = ''; loadFavorites(); } async function deleteFavorite(id) { if (!confirm('Remove this favorite?')) return; await api(`/favorites/${id}`, { method: 'DELETE' }); loadFavorites(); } async function toggleFavorite(url, name, favoriteId) { if (favoriteId) { await api(`/favorites/${favoriteId}`, { method: 'DELETE' }); } else { await api('/favorites', { method: 'POST', body: JSON.stringify({ type: 'product', value: url, display_name: name, priority: 'high' }) }); } loadProducts(); } // Utilities function updateFilterBadge() { const badge = document.getElementById('eventFilterBadge'); if (!badge) return; if (currentEventFilter) { const typeLabel = currentEventFilter.type === 'new_drop' ? 'New Drops' : 'Restocks'; const periodLabel = currentEventFilter.period === 'today' ? 'Today' : '7 Days'; badge.innerHTML = ` ${typeLabel} - ${periodLabel} × `; badge.style.display = 'flex'; } else { badge.style.display = 'none'; } } function formatTime(date) { const now = new Date(); const diff = (now - date) / 1000; if (diff < 60) return 'Just now'; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return date.toLocaleDateString(); } function formatDate(dateStr) { if (!dateStr) return 'N/A'; return new Date(dateStr).toLocaleString(); } function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function cleanProductName(name) { if (!name) return ''; // Fix common UTF-8 encoding issues (mojibake) return name // Remove SVG/CSS class garbage (pikachu_svg__st1{fill:#7c888f}.review-full-...) .replace(/[\w-]*_svg__[\w\s{}:#.;-]+/gi, '') .replace(/\.review-full-[\w\s{}:#.;-]+/gi, '') .replace(/\{[^}]*\}/g, '') // Remove any remaining {css} blocks .replace(/\d+\s*Reviews?$/i, '') // Remove trailing "X Reviews" // Fix Pokémon - multiple encoding patterns .replace(/Pok[éÃ\u00c3\u00a9\ufffd]+mon/gi, 'Pokémon') .replace(/Pokémon/gi, 'Pokémon') .replace(/Pokémon/gi, 'Pokémon') .replace(/Pokテゥmon/gi, 'Pokémon') // Keep "Pokemon" as-is for non-encoded versions // Fix dashes - â€" is UTF-8 em-dash mangled .replace(/â€"/g, '–') .replace(/â€"/g, '-') .replace(/â€""/g, '–') // Fix apostrophes and quotes .replace(/’/g, "'") .replace(/‘/g, "'") .replace(/“/g, '"') .replace(/â€[^a-zA-Z0-9]/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") .replace(/"/g, '"') .replace(/"/g, '"') // Fix accented characters .replace(/é/g, 'é') .replace(/è/g, 'è') .replace(/à /g, 'à') .replace(/â/g, 'â') .replace(/î/g, 'î') .replace(/ô/g, 'ô') .replace(/û/g, 'û') // Fix trademark symbols .replace(/â„¢/g, '™') .replace(/®/g, '®') // Clean up any remaining garbled chars .replace(/Â/g, '') // Clean up any double spaces .replace(/\s+/g, ' ') .trim(); } // ==================== Control Panel ==================== let controlPanelInterval = null; async function loadControlPanel() { const state = await api('/scrapers/state'); if (!state) return; // Update monitor status updateMonitorStatus(state); // Update check interval input document.getElementById('checkIntervalInput').value = state.check_interval || 60; // Render scrapers grid renderScrapersGrid(state.scrapers); // Load Chrome Extension status loadExtensionStatus(); // Setup event listeners for control panel buttons setupControlPanelListeners(); // Auto-refresh control panel every 5 seconds if (controlPanelInterval) clearInterval(controlPanelInterval); controlPanelInterval = setInterval(async () => { if (currentPage === 'control') { const newState = await api('/scrapers/state'); if (newState) { updateMonitorStatus(newState); renderScrapersGrid(newState.scrapers); } loadExtensionStatus(); } }, 5000); } async function loadExtensionStatus() { const badge = document.getElementById('extensionBadge'); const productCount = document.getElementById('extProductCount'); const skuCount = document.getElementById('extSkuCount'); const lastSync = document.getElementById('extLastSync'); const stats = await api('/extension/stats'); if (stats && stats.last_sync) { // Extension has synced data badge.textContent = 'Connected'; badge.className = 'extension-badge connected'; productCount.textContent = stats.total_products || 0; skuCount.textContent = stats.total_skus || 0; lastSync.textContent = formatTime(new Date(stats.last_sync)); } else { // No sync yet badge.textContent = 'No Data'; badge.className = 'extension-badge disconnected'; productCount.textContent = '-'; skuCount.textContent = '-'; lastSync.textContent = 'Never'; } } function updateMonitorStatus(state) { const badge = document.getElementById('monitorBadge'); const statusText = document.getElementById('monitorStatusText'); const lastCheck = document.getElementById('controlLastCheck'); const startBtn = document.getElementById('startMonitorBtn'); const stopBtn = document.getElementById('stopMonitorBtn'); if (state.monitor_running) { badge.textContent = 'Running'; badge.className = 'monitor-badge running'; statusText.textContent = `Monitor is running (PID: ${state.monitor_pid})`; startBtn.disabled = true; stopBtn.disabled = false; } else { badge.textContent = 'Stopped'; badge.className = 'monitor-badge stopped'; statusText.textContent = 'Monitor is not running'; startBtn.disabled = false; stopBtn.disabled = true; } if (state.last_check) { lastCheck.textContent = formatTime(new Date(state.last_check)); } else { lastCheck.textContent = 'Never'; } } function renderScrapersGrid(scrapers) { const grid = document.getElementById('scrapersGrid'); grid.innerHTML = Object.entries(scrapers).map(([key, data]) => { const info = STORE_INFO[key] || { name: key, logo: '', fallback: key.substring(0, 2).toUpperCase(), bgColor: '#333', color: '#fff' }; const enabledClass = data.enabled ? 'enabled' : 'disabled'; const runningClass = data.running ? 'running' : ''; return `
${info.name}
${data.running ? '● Running' : data.enabled ? '● Enabled' : '○ Disabled'}
${data.last_run ? `
Last: ${formatTime(new Date(data.last_run))}
` : ''} ${data.last_error ? `
${data.last_error}
` : ''}
`; }).join(''); } function setupControlPanelListeners() { // Start/Stop buttons document.getElementById('startMonitorBtn')?.addEventListener('click', startMonitor); document.getElementById('stopMonitorBtn')?.addEventListener('click', stopMonitor); // Save interval button document.getElementById('saveIntervalBtn')?.addEventListener('click', saveCheckInterval); // Quick actions document.getElementById('runCheckNowBtn')?.addEventListener('click', runCheckNow); document.getElementById('viewLogsBtn')?.addEventListener('click', viewLogs); } async function toggleScraper(scraper, enabled) { const result = await api(`/scrapers/${scraper}/toggle`, { method: 'POST', body: JSON.stringify({ enabled }) }); if (result && result.success) { // Refresh the control panel loadControlPanel(); } else { alert('Failed to toggle scraper: ' + (result?.error || 'Unknown error')); } } async function startMonitor() { const btn = document.getElementById('startMonitorBtn'); btn.disabled = true; btn.innerHTML = ' Starting...'; const result = await api('/scrapers/start', { method: 'POST' }); if (result && result.success) { loadControlPanel(); } else { alert('Failed to start monitor: ' + (result?.error || 'Unknown error')); btn.disabled = false; btn.innerHTML = ' Start Monitor'; } } async function stopMonitor() { const btn = document.getElementById('stopMonitorBtn'); btn.disabled = true; btn.innerHTML = ' Stopping...'; const result = await api('/scrapers/stop', { method: 'POST' }); if (result && result.success) { loadControlPanel(); } else { alert('Failed to stop monitor: ' + (result?.error || 'Unknown error')); btn.disabled = false; btn.innerHTML = ' Stop Monitor'; } } async function saveCheckInterval() { const input = document.getElementById('checkIntervalInput'); const interval = parseInt(input.value); if (isNaN(interval) || interval < 10) { alert('Interval must be at least 10 seconds'); return; } const result = await api('/scrapers/interval', { method: 'POST', body: JSON.stringify({ interval }) }); if (result && result.success) { alert('Check interval saved!'); } else { alert('Failed to save interval: ' + (result?.error || 'Unknown error')); } } async function runCheckNow() { const result = await api('/check', { method: 'POST' }); if (result) { alert(result.message || 'Check triggered'); } } function viewLogs() { // Open monitor.log in a new window or show in modal alert('View monitor.log file in the project directory for detailed logs.'); } // ==================== News ==================== let newsInitialized = false; async function loadNews() { // Initialize event listeners once if (!newsInitialized) { initNewsEventListeners(); newsInitialized = true; } // Load stats and articles await Promise.all([ loadNewsStats(), loadNewsArticles() ]); } function initNewsEventListeners() { // Filter changes document.getElementById('filterNewsSource')?.addEventListener('change', loadNewsArticles); document.getElementById('filterNewsSentiment')?.addEventListener('change', loadNewsArticles); document.getElementById('filterDropRelated')?.addEventListener('change', loadNewsArticles); // Refresh button document.getElementById('refreshNewsBtn')?.addEventListener('click', refreshNews); // Manual news input document.getElementById('addManualNewsBtn')?.addEventListener('click', addManualNews); } async function loadNewsStats() { const stats = await api('/news/stats'); if (!stats) return; // Update sentiment bar const total = (stats.by_sentiment?.positive || 0) + (stats.by_sentiment?.neutral || 0) + (stats.by_sentiment?.negative || 0); if (total > 0) { const positivePercent = (stats.by_sentiment?.positive || 0) / total * 100; const neutralPercent = (stats.by_sentiment?.neutral || 0) / total * 100; const negativePercent = (stats.by_sentiment?.negative || 0) / total * 100; document.getElementById('sentimentPositiveBar').style.width = positivePercent + '%'; document.getElementById('sentimentNeutralBar').style.width = neutralPercent + '%'; document.getElementById('sentimentNegativeBar').style.width = negativePercent + '%'; document.getElementById('sentimentText').textContent = `${Math.round(positivePercent)}% Positive, ${Math.round(neutralPercent)}% Neutral, ${Math.round(negativePercent)}% Negative`; } else { document.getElementById('sentimentText').textContent = 'No sentiment data yet'; } // Update today count document.getElementById('todayArticles').textContent = stats.today || 0; } async function loadNewsArticles() { const source = document.getElementById('filterNewsSource')?.value || ''; const sentiment = document.getElementById('filterNewsSentiment')?.value || ''; const dropRelated = document.getElementById('filterDropRelated')?.checked || false; let url = '/news?limit=50'; if (source) url += `&source=${source}`; if (sentiment) url += `&sentiment=${sentiment}`; if (dropRelated) url += '&drop_related=true'; const data = await api(url); if (!data || !data.articles) return; const feed = document.getElementById('newsFeed'); if (data.articles.length === 0) { feed.innerHTML = '
No news articles yet. Click "Refresh News" to fetch from sources.
'; return; } feed.innerHTML = data.articles.map(article => { const sourceIcon = getSourceIcon(article.source); const sentimentBadge = getSentimentBadge(article.sentiment_label, article.sentiment_score); const timeAgo = formatTime(new Date(article.published_at || article.fetched_at)); const keywords = article.keywords || []; const hasCorrelation = article.is_drop_related || article.is_restock_related; return `
${sourceIcon} ${escapeHtml(article.source_account)} ${timeAgo} ${sentimentBadge}
${article.title ? `
${escapeHtml(article.title)}
` : ''}
${escapeHtml(article.content)}
${article.url ? `View Original` : ''} ${keywords.length > 0 ? `
${keywords.slice(0, 5).map(kw => `${escapeHtml(kw)}`).join('')}
` : ''} ${hasCorrelation ? `
${article.is_drop_related ? 'Drop Related' : ''} ${article.is_restock_related ? 'Restock Related' : ''}
` : ''}
`; }).join(''); } function getSourceIcon(source) { switch (source) { case 'twitter': return 'X'; case 'pokemon_official': return ''; case 'discord_manual': return '💬'; default: return '📄'; } } function getSentimentBadge(label, score) { if (!label) return ''; let badgeClass = 'neutral'; let symbol = '●'; if (label === 'positive') { badgeClass = 'positive'; symbol = '+'; } else if (label === 'negative') { badgeClass = 'negative'; symbol = '−'; } const scoreText = score !== null ? ` (${score.toFixed(2)})` : ''; return `${symbol} ${label}${scoreText}`; } async function refreshNews() { const btn = document.getElementById('refreshNewsBtn'); btn.disabled = true; btn.innerHTML = ' Fetching...'; const result = await api('/news/refresh', { method: 'POST' }); btn.disabled = false; btn.innerHTML = ' Refresh News'; if (result && result.success) { const msg = `Fetched ${result.fetched} articles.`; if (result.errors && result.errors.length > 0) { alert(msg + '\n\nErrors:\n' + result.errors.join('\n')); } // Reload news loadNews(); } else { alert('Failed to refresh news'); } } async function addManualNews() { const source = document.getElementById('manualNewsSource')?.value || 'Other'; const title = document.getElementById('manualNewsTitle')?.value.trim() || null; const content = document.getElementById('manualNewsContent')?.value.trim(); const url = document.getElementById('manualNewsUrl')?.value.trim() || null; if (!content) { alert('Please enter the message content'); return; } const result = await api('/news/manual', { method: 'POST', body: JSON.stringify({ source_account: source, title: title, content: content, url: url }) }); if (result && result.success) { // Clear form document.getElementById('manualNewsTitle').value = ''; document.getElementById('manualNewsContent').value = ''; document.getElementById('manualNewsUrl').value = ''; // Show analysis result const analysis = result.analysis; alert(`News added!\n\nSentiment: ${analysis.sentiment_label} (${analysis.sentiment_score.toFixed(2)})\nDrop related: ${analysis.is_drop_related}\nRestock related: ${analysis.is_restock_related}`); // Reload news loadNews(); } else { alert('Failed to add news: ' + (result?.error || 'Unknown error')); } } async function deleteNewsArticle(articleId) { if (!confirm('Delete this news article?')) return; const result = await api(`/news/${articleId}`, { method: 'DELETE' }); if (result && result.success) { loadNewsArticles(); } else { alert('Failed to delete article'); } } // ==================== Users Page ==================== let usersInitialized = false; async function loadUsers() { // Initialize event listeners once if (!usersInitialized) { document.getElementById('addUserBtn')?.addEventListener('click', addUser); usersInitialized = true; } const data = await api('/users'); if (!data) return; renderUsersList(data.users || []); } function renderUsersList(users) { const container = document.getElementById('usersList'); if (!container) return; if (users.length === 0) { container.innerHTML = '

No users yet. Add one above!

'; return; } container.innerHTML = users.map(user => `
${escapeHtml(user.name)} ${user.zip_code ? `${user.zip_code} (${user.radius_miles}mi)` : 'No location set'}
`).join(''); } async function addUser() { const nameInput = document.getElementById('newUserName'); const zipInput = document.getElementById('newUserZip'); const radiusInput = document.getElementById('newUserRadius'); const name = nameInput.value.trim(); const zip_code = zipInput.value.trim() || null; const radius_miles = parseInt(radiusInput.value) || 25; if (!name) { alert('Please enter a name'); return; } const result = await api('/users', { method: 'POST', body: JSON.stringify({ name, zip_code, radius_miles }) }); if (result && result.success) { nameInput.value = ''; zipInput.value = ''; radiusInput.value = '25'; loadUsers(); } else { alert('Failed to add user: ' + (result?.error || 'Unknown error')); } } async function updateUserLocation(userId) { const card = document.querySelector(`[data-user-id="${userId}"]`); const zip_code = card.querySelector('.user-zip-input').value.trim(); const radius_miles = parseInt(card.querySelector('.user-radius-input').value) || 25; if (!zip_code) { alert('Please enter a zip code'); return; } const result = await api(`/users/${userId}/location`, { method: 'PUT', body: JSON.stringify({ zip_code, radius_miles }) }); if (result && result.success) { loadUsers(); } else { alert('Failed to update location: ' + (result?.error || 'Unknown error')); } } async function viewUserStores(userId) { const storesDiv = document.getElementById(`userStores-${userId}`); // Toggle visibility if (storesDiv.style.display !== 'none') { storesDiv.style.display = 'none'; return; } storesDiv.innerHTML = '

Loading stores...

'; storesDiv.style.display = 'block'; const result = await api(`/users/${userId}/stores`); if (!result || result.error) { storesDiv.innerHTML = `

${result?.error || 'Failed to load stores'}

`; return; } let html = `
`; for (const [retailer, stores] of Object.entries(result.stores)) { if (stores.length > 0) { html += `

${retailer} (${stores.length} stores)

`; } } html += '
'; storesDiv.innerHTML = html; } async function deleteUser(userId) { if (!confirm('Delete this user?')) return; const result = await api(`/users/${userId}`, { method: 'DELETE' }); if (result && result.success) { loadUsers(); } else { alert('Failed to delete user: ' + (result?.error || 'Unknown error')); } } // ==================== Settings Page ==================== let settingsInitialized = false; async function loadSettings() { // Initialize event listeners once if (!settingsInitialized) { document.getElementById('checkBrokenUrlsBtn')?.addEventListener('click', checkBrokenUrls); document.getElementById('cleanupBrokenUrlsBtn')?.addEventListener('click', cleanupBrokenUrls); document.getElementById('deduplicateBtn')?.addEventListener('click', deduplicateProducts); document.getElementById('deduplicateEventsBtn')?.addEventListener('click', deduplicateEvents); settingsInitialized = true; } } async function checkBrokenUrls() { const resultEl = document.getElementById('brokenUrlsResult'); resultEl.textContent = 'Checking...'; const result = await api('/extension/broken-urls'); if (result) { resultEl.textContent = `Found ${result.count} products with broken Pokemon Center URLs.`; } else { resultEl.textContent = 'Failed to check broken URLs'; } } async function cleanupBrokenUrls() { if (!confirm('This will delete all Pokemon Center products with broken URLs. Continue?')) return; const resultEl = document.getElementById('brokenUrlsResult'); resultEl.textContent = 'Cleaning up...'; const result = await api('/extension/broken-urls/cleanup', { method: 'POST' }); if (result && result.success) { resultEl.textContent = `Deleted ${result.deleted} products with broken URLs.`; } else { resultEl.textContent = 'Failed to cleanup broken URLs'; } } async function deduplicateProducts() { if (!confirm('This will remove duplicate products. Continue?')) return; const resultEl = document.getElementById('deduplicateResult'); resultEl.textContent = 'Removing duplicates...'; const result = await api('/products/deduplicate', { method: 'POST' }); if (result && result.success) { let message = `Found ${result.duplicates_found} duplicate groups, removed ${result.products_removed} products.`; if (Object.keys(result.by_site).length > 0) { message += ' By site: ' + Object.entries(result.by_site).map(([site, count]) => `${site}: ${count}`).join(', '); } resultEl.textContent = message; } else { resultEl.textContent = 'Failed to deduplicate products'; } } async function deduplicateEvents() { if (!confirm('This will remove duplicate events from the activity feed. Continue?')) return; const resultEl = document.getElementById('deduplicateEventsResult'); resultEl.textContent = 'Cleaning up events...'; const result = await api('/events/deduplicate', { method: 'POST' }); if (result && result.success) { resultEl.textContent = `Removed ${result.events_removed} duplicate events.`; } else { resultEl.textContent = 'Failed to clean up events'; } }