// 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' }
// 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
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;
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.fallback}
${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 = `
${storeInfo.fallback}
`;
return `
${hasProductImage
? `

`
: storeLogoHtml
}
${storeInfo.name} • ${event.current_price || 'N/A'}
${formatTime(new Date(event.recorded_at))}
`;
}).join('');
}
// Navigate to products filtered by event type
function goToFilteredProducts(eventType, period) {
currentEventFilter = { type: eventType, period: period };
navigateTo('products');
}
// Clear event filter
function clearEventFilter() {
currentEventFilter = null;
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;
let url = '/products?limit=50';
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
';
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
? `

`
: `
${storeInfo.fallback}
`
}
${storeInfo.fallback}
${cleanName}
${product.current_price || 'N/A'}
${product.in_stock ? 'IN STOCK' : 'OUT OF STOCK'}
${storeInfo.name}
`;
}).join('');
}
// 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 = `
${storeInfo.fallback}
`;
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
${history.price_history.map(p => `- ${p.price} - ${formatDate(p.recorded_at)}
`).join('')}
` : ''}
`;
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
// Fix Pokémon - multiple encoding patterns
.replace(/Pok[éÃ\u00c3\u00a9]+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);
// 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);
}
}
}, 5000);
}
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.fallback}
${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 `
${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 => `
`).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)
${stores.slice(0, 5).map(s => `
-
${escapeHtml(s.name)} (${s.distance_miles.toFixed(1)} mi)
${escapeHtml(s.address)}, ${escapeHtml(s.city)}, ${s.state}
`).join('')}
`;
}
}
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);
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';
}
}