FoodShopper PR

🛒

Search for a product to compare prices across SuperMax, Econo and Amigo

My Shopping List

📝

Your shopping list is empty

Search for products and add them to your list

// ── State ──────────────────────────────────────────────────────────── let allResults = []; // flat array of normalized products from last search let cachedByKey = {}; // cumulative: productKey -> { store -> product } across all searches let shoppingList = JSON.parse(localStorage.getItem('foodshopper_list') || '[]'); let activeFilters = new Set(['SuperMax', 'Econo', 'Amigo']); updateBadge(); // ── Search ─────────────────────────────────────────────────────────── document.getElementById('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); function doSearch() { const q = document.getElementById('searchInput').value.trim(); if (!q) return; switchView('search'); document.getElementById('searchPlaceholder').style.display = 'none'; document.getElementById('searchLoading').style.display = 'block'; document.getElementById('searchResults').innerHTML = ''; document.getElementById('searchErrors').style.display = 'none'; document.getElementById('filterBar').style.display = 'none'; fetch('/api/search?q=' + encodeURIComponent(q)) .then(r => r.json()) .then(data => { document.getElementById('searchLoading').style.display = 'none'; if (data.errors && data.errors.length) { const el = document.getElementById('searchErrors'); el.textContent = data.errors.join(' | '); el.style.display = 'block'; } allResults = []; for (const [store, products] of Object.entries(data.results || {})) { allResults.push(...products); } // Accumulate into cache for (const p of allResults) { if (p.price == null) continue; const key = productKey(p.name); if (!cachedByKey[key]) cachedByKey[key] = {}; if (!cachedByKey[key][p.store] || p.price < cachedByKey[key][p.store].price) { cachedByKey[key][p.store] = p; } } document.getElementById('filterBar').style.display = allResults.length ? 'flex' : 'none'; renderResults(); }) .catch(err => { document.getElementById('searchLoading').style.display = 'none'; document.getElementById('searchErrors').textContent = 'Network error: ' + err.message; document.getElementById('searchErrors').style.display = 'block'; }); } // ── Render search results ──────────────────────────────────────────── function renderProductCard(p, bestPrices) { const storeClass = 'store-' + p.store.toLowerCase().replace(/\s/g, ''); const priceStr = p.price != null ? '$' + p.price.toFixed(2) : 'N/A'; const key = productKey(p.name); const isBest = p.price != null && bestPrices[key] === p.price; const inList = shoppingList.some(i => i.id === p.id && i.store === p.store); const img = p.image ? `` : '📦'; return `
${escHtml(p.store)}
${img}
${escHtml(p.name)}
${priceStr} ${p.unit ? '/ ' + escHtml(p.unit) + '' : ''} ${isBest && allResults.filter(x => productKey(x.name) === key).length > 1 ? 'BEST PRICE' : ''}
${p.promo ? '
' + escHtml(p.promo) + '
' : ''}
`; } function renderResults() { const container = document.getElementById('searchResults'); let items = allResults.filter(p => activeFilters.has(p.store)); const sort = document.getElementById('sortSelect').value; // Find best price per similar product name (normalized key) const bestPrices = {}; for (const p of allResults) { const key = productKey(p.name); if (p.price != null && (!bestPrices[key] || p.price < bestPrices[key])) { bestPrices[key] = p.price; } } document.getElementById('resultCount').textContent = items.length + ' products'; if (!items.length) { container.innerHTML = '

No products found

'; return; } if (sort === 'grouped') { // Group by store, each section sorted by price const storeOrder = ['SuperMax', 'Econo', 'Amigo']; const dotClass = { 'SuperMax': 'dot-supermax', 'Econo': 'dot-econo', 'Amigo': 'dot-amigo' }; let html = ''; for (const store of storeOrder) { const storeItems = items.filter(p => p.store === store); if (!storeItems.length) continue; storeItems.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity)); html += `

${escHtml(store)}

${storeItems.length} products
`; html += storeItems.map(p => renderProductCard(p, bestPrices)).join(''); } container.innerHTML = html; } else { items.sort((a, b) => { const pa = a.price ?? Infinity, pb = b.price ?? Infinity; if (sort === 'price-asc') return pa - pb; if (sort === 'price-desc') return pb - pa; if (sort === 'name') return (a.name || '').localeCompare(b.name || ''); return 0; }); container.innerHTML = items.map(p => renderProductCard(p, bestPrices)).join(''); } } // ── Shopping list ──────────────────────────────────────────────────── function addToList(jsonStr) { const p = JSON.parse(jsonStr); const existing = shoppingList.find(i => i.id === p.id && i.store === p.store); if (existing) { existing.qty = (existing.qty || 1) + 1; } else { shoppingList.push({ ...p, qty: 1 }); } saveList(); renderResults(); renderList(); } function removeFromList(idx) { shoppingList.splice(idx, 1); saveList(); renderResults(); renderList(); } function updateQty(idx, val) { const qty = parseInt(val) || 1; if (qty < 1) { removeFromList(idx); return; } shoppingList[idx].qty = qty; saveList(); renderList(); } function clearList() { if (!shoppingList.length || !confirm('Clear entire shopping list?')) return; shoppingList = []; saveList(); renderResults(); renderList(); } function swapToAlt(idx, jsonStr) { const alt = JSON.parse(jsonStr); const old = shoppingList[idx]; shoppingList[idx] = { ...alt, qty: old.qty || 1 }; saveList(); renderResults(); renderList(); } function swapAllToCheapest() { // Build alternatives lookup from cache + current results + list const altsByKey = {}; for (const [key, storeMap] of Object.entries(cachedByKey)) { altsByKey[key] = { ...storeMap }; } for (const p of allResults) { if (p.price == null) continue; const key = productKey(p.name); if (!altsByKey[key]) altsByKey[key] = {}; if (!altsByKey[key][p.store] || p.price < altsByKey[key][p.store].price) { altsByKey[key][p.store] = p; } } for (const p of shoppingList) { if (p.price == null) continue; const key = productKey(p.name); if (!altsByKey[key]) altsByKey[key] = {}; if (!altsByKey[key][p.store] || p.price < altsByKey[key][p.store].price) { altsByKey[key][p.store] = p; } } let swapped = 0; for (let i = 0; i < shoppingList.length; i++) { const p = shoppingList[i]; const key = productKey(p.name); const alts = altsByKey[key] || {}; let cheapest = null; let cheapestPrice = p.price ?? Infinity; for (const [s, alt] of Object.entries(alts)) { if (alt.price != null && alt.price < cheapestPrice) { cheapestPrice = alt.price; cheapest = alt; } } if (cheapest && cheapest.store !== p.store) { shoppingList[i] = { ...cheapest, qty: p.qty || 1 }; swapped++; } } if (swapped > 0) { saveList(); renderResults(); renderList(); } } function saveList() { localStorage.setItem('foodshopper_list', JSON.stringify(shoppingList)); updateBadge(); } function updateBadge() { document.getElementById('listBadge').textContent = shoppingList.length; } function renderList() { const empty = document.getElementById('listEmpty'); const content = document.getElementById('listContent'); if (!shoppingList.length) { empty.style.display = 'block'; content.style.display = 'none'; return; } empty.style.display = 'none'; content.style.display = 'block'; const body = document.getElementById('listBody'); // Build alternatives from cumulative cache + current search + list items const altsByKey = {}; // Start with cached results from all previous searches for (const [key, storeMap] of Object.entries(cachedByKey)) { altsByKey[key] = { ...storeMap }; } // Layer in current search results for (const p of allResults) { if (p.price == null) continue; const key = productKey(p.name); if (!altsByKey[key]) altsByKey[key] = {}; if (!altsByKey[key][p.store] || p.price < altsByKey[key][p.store].price) { altsByKey[key][p.store] = p; } } // Also include the list items themselves for (const p of shoppingList) { if (p.price == null) continue; const key = productKey(p.name); if (!altsByKey[key]) altsByKey[key] = {}; if (!altsByKey[key][p.store] || p.price < altsByKey[key][p.store].price) { altsByKey[key][p.store] = p; } } // Check if any list items are missing alternatives — auto-search for them const missingKeys = []; for (const p of shoppingList) { const key = productKey(p.name); const alts = altsByKey[key] || {}; if (Object.keys(alts).length < 3) { missingKeys.push({ key, name: p.name }); } } if (missingKeys.length > 0 && !renderList._fetching) { renderList._fetching = true; // Fetch alternatives for missing items const uniqueNames = [...new Set(missingKeys.map(m => m.name))]; const fetches = uniqueNames.map(name => { const words = name.split(/\s+/).filter(w => w.length > 2).slice(0, 3).join(' '); return fetch('/api/search?q=' + encodeURIComponent(words)) .then(r => r.json()) .then(data => { for (const products of Object.values(data.results || {})) { for (const p of products) { if (p.price == null) continue; const k = productKey(p.name); if (!cachedByKey[k]) cachedByKey[k] = {}; if (!cachedByKey[k][p.store] || p.price < cachedByKey[k][p.store].price) { cachedByKey[k][p.store] = p; } } } }).catch(() => {}); }); Promise.all(fetches).then(() => { renderList._fetching = false; renderList(); }); } body.innerHTML = shoppingList.map((p, i) => { const sub = (p.price || 0) * (p.qty || 1); const storeClass = 'store-' + p.store.toLowerCase().replace(/\s/g, ''); const key = productKey(p.name); const alts = altsByKey[key] || {}; const stores = ['SuperMax', 'Econo', 'Amigo']; // Find cheapest alternative let cheapestStore = null; let cheapestPrice = p.price ?? Infinity; for (const s of stores) { if (alts[s] && alts[s].price != null && alts[s].price < cheapestPrice) { cheapestPrice = alts[s].price; cheapestStore = s; } } // Build alt chips let altHtml = '
'; for (const s of stores) { if (!alts[s]) continue; const isCurrent = s === p.store; const isCheapest = !isCurrent && s === cheapestStore; const cls = isCurrent ? 'alt-chip current' : isCheapest ? 'alt-chip cheapest' : 'alt-chip'; const label = s + ' $' + alts[s].price.toFixed(2); if (isCurrent) { altHtml += `${label} ✓`; } else { altHtml += `${label}${isCheapest ? ' ★' : ''}`; } } altHtml += '
'; return ` ${escHtml(p.name)}${altHtml} ${escHtml(p.store)} ${p.price != null ? '$' + p.price.toFixed(2) : 'N/A'} $${sub.toFixed(2)} `; }).join(''); computeTotals(); } // ── Totals computation ─────────────────────────────────────────────── function computeTotals() { const grid = document.getElementById('totalsGrid'); // 1. Per-store totals: if I bought everything at each store const stores = ['SuperMax', 'Econo', 'Amigo']; const storeTotals = {}; const storeMissing = {}; for (const store of stores) { storeTotals[store] = 0; storeMissing[store] = []; } // Group list items by a normalized name key to find cross-store matches const itemsByKey = {}; for (const item of shoppingList) { const key = productKey(item.name); if (!itemsByKey[key]) itemsByKey[key] = {}; itemsByKey[key][item.store] = item; } // Scan cumulative cache + current results for cross-store equivalents const allSources = []; for (const [key, storeMap] of Object.entries(cachedByKey)) { for (const p of Object.values(storeMap)) allSources.push(p); } for (const p of allResults) allSources.push(p); for (const p of allSources) { if (p.price == null) continue; const pKey = productKey(p.name); // Only add if this key exists in the shopping list (i.e. a matching product) if (itemsByKey[pKey] && !itemsByKey[pKey][p.store]) { itemsByKey[pKey][p.store] = p; } } // For each store, sum up: use item from that store if available, else mark missing for (const store of stores) { let total = 0; let missing = []; let count = 0; for (const [key, storeItems] of Object.entries(itemsByKey)) { // Get qty from the item the user actually added (not from search results) const userItem = shoppingList.find(i => productKey(i.name) === key); const qty = userItem ? (userItem.qty || 1) : 1; if (storeItems[store] && storeItems[store].price != null) { total += storeItems[store].price * qty; count++; } else { const anyItem = Object.values(storeItems)[0]; missing.push(anyItem.name); } } storeTotals[store] = total; storeMissing[store] = missing; } // 2. Mixed (cherry-pick best price per item) let mixedTotal = 0; const mixedDetails = []; for (const [key, storeItems] of Object.entries(itemsByKey)) { let best = null; let bestPrice = Infinity; for (const [store, item] of Object.entries(storeItems)) { if (item.price != null && item.price < bestPrice) { bestPrice = item.price; best = item; } } if (best) { const userItem = shoppingList.find(i => productKey(i.name) === key); const qty = userItem ? (userItem.qty || 1) : 1; mixedTotal += bestPrice * qty; mixedDetails.push({ name: best.name, store: best.store, price: bestPrice, qty }); } } // 3. Per-item actual total (what's currently in the list as-is) let actualTotal = 0; for (const item of shoppingList) { actualTotal += (item.price || 0) * (item.qty || 1); } // Find best total const allTotals = { ...storeTotals, 'Best Mix': mixedTotal }; const bestStore = Object.keys(allTotals).reduce((a, b) => { // Only consider stores that have all items const aMissing = a === 'Best Mix' ? 0 : storeMissing[a].length; const bMissing = b === 'Best Mix' ? 0 : storeMissing[b].length; if (aMissing === 0 && bMissing > 0) return a; if (bMissing === 0 && aMissing > 0) return b; return allTotals[a] <= allTotals[b] ? a : b; }); const storeColorClass = { 'SuperMax': 'tc-supermax', 'Econo': 'tc-econo', 'Amigo': 'tc-amigo', 'Best Mix': 'tc-mixed' }; let html = ''; // Mixed card first html += `
${bestStore === 'Best Mix' ? 'CHEAPEST' : ''}
🏆 Best Price Mix
$${mixedTotal.toFixed(2)}
Cherry-pick cheapest from each store
${actualTotal > mixedTotal ? '
Save $' + (actualTotal - mixedTotal).toFixed(2) + ' vs your current list
' : ''}
`; // Your current list card html += `
📋 Your Current List
$${actualTotal.toFixed(2)}
${shoppingList.length} item${shoppingList.length !== 1 ? 's' : ''} as selected
`; // Per-store cards for (const store of stores) { const missing = storeMissing[store]; const itemCount = Object.keys(itemsByKey).length - missing.length; html += `
${bestStore === store ? 'CHEAPEST' : ''}
${escHtml(store)} Only
${missing.length === Object.keys(itemsByKey).length ? 'N/A' : '$' + storeTotals[store].toFixed(2)}
${itemCount} of ${Object.keys(itemsByKey).length} items available
${missing.length ? '
Missing: ' + missing.map(n => escHtml(n.substring(0, 30))).join(', ') + '
' : ''} ${bestStore === store && mixedTotal < storeTotals[store] ? '
$' + (storeTotals[store] - mixedTotal).toFixed(2) + ' more than best mix
' : ''}
`; } grid.innerHTML = html; } // ── View switching ─────────────────────────────────────────────────── function switchView(view) { document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); document.querySelectorAll('.tab-bar button').forEach(b => b.classList.remove('active')); document.getElementById('view' + view.charAt(0).toUpperCase() + view.slice(1)).classList.add('active'); document.getElementById('tab' + view.charAt(0).toUpperCase() + view.slice(1)).classList.add('active'); if (view === 'list') renderList(); } // ── Store filter toggles ───────────────────────────────────────────── function toggleStoreFilter(el) { const store = el.dataset.store; if (activeFilters.has(store)) { if (activeFilters.size > 1) { activeFilters.delete(store); el.classList.remove('active'); } } else { activeFilters.add(store); el.classList.add('active'); } renderResults(); } // ── Utilities ──────────────────────────────────────────────────────── const _stopWords = new Set(['de','del','en','el','la','los','las','con','y','a','e','o','para','por','un','una','sin','al','su']); const _unitPattern = /^\d+[.,]?\d*\s*(oz|lb|lbs|g|gr|kg|ml|lt|l|fl|ct|pk|und|unit|pack|ea|each|count|gal|pt|qt)$/i; const _pureNumber = /^\d+[.,]?\d*$/; function productKey(name) { if (!name) return ''; const words = name.toLowerCase().replace(/[^a-z0-9áéíóúñü\s]/g, '').split(/\s+/).filter(w => w.length > 0); // Remove stop words, size tokens ("5 oz" -> ["5", "oz"]), and pure numbers const filtered = []; for (let i = 0; i < words.length; i++) { const w = words[i]; if (_stopWords.has(w)) continue; if (_pureNumber.test(w)) { // Check if next word is a unit — if so skip both const next = words[i + 1] || ''; if (/^(oz|lb|lbs|g|gr|kg|ml|lt|l|fl|ct|pk|und|unit|pack|ea|each|count|gal|pt|qt)$/i.test(next)) { i++; continue; } continue; // skip standalone numbers too } if (/^(oz|lb|lbs|g|gr|kg|ml|lt|l|fl|ct|pk|und|unit|pack|ea|each|count|gal|pt|qt)$/i.test(w)) continue; filtered.push(w); } return filtered.sort().join(' '); } function escHtml(str) { if (!str) return ''; const d = document.createElement('div'); d.textContent = String(str); return d.innerHTML; } // ── Price History Chart ────────────────────────────────────────────── function showPriceHistory(productName) { const key = productKey(productName); const modal = document.getElementById('priceModal'); document.getElementById('modalTitle').textContent = productName; modal.style.display = 'flex'; document.getElementById('priceStats').innerHTML = '
Loading price history...
'; // Clear previous chart if (window._priceChart) { window._priceChart.destroy(); window._priceChart = null; } const canvas = document.getElementById('priceChart'); canvas.style.display = 'none'; fetch('/api/price-history?key=' + encodeURIComponent(key)) .then(r => r.json()) .then(data => { if (!data.history || !data.history.length) { canvas.style.display = 'none'; document.getElementById('priceStats').innerHTML = '

📊

' + '

No price history available yet.

' + '

Data is collected daily. Check back tomorrow!

'; return; } canvas.style.display = 'block'; renderPriceChart(data); renderPriceStats(data); }) .catch(err => { document.getElementById('priceStats').innerHTML = '

Failed to load price history: ' + escHtml(err.message) + '

'; }); } function closePriceModal() { document.getElementById('priceModal').style.display = 'none'; if (window._priceChart) { window._priceChart.destroy(); window._priceChart = null; } } function renderPriceChart(data) { const ctx = document.getElementById('priceChart').getContext('2d'); const colors = { 'SuperMax': '#e63946', 'Econo': '#2a9d8f', 'Amigo': '#c8a415' }; const stores = Object.keys(data.products || {}); const allDates = data.history.map(h => h.date); const datasets = stores.map(store => { return { label: store, data: allDates.map(d => { const entry = data.history.find(h => h.date === d); return entry ? (entry[store] ?? null) : null; }), borderColor: colors[store] || '#999', backgroundColor: (colors[store] || '#999') + '20', tension: 0.3, spanGaps: true, pointRadius: allDates.length > 60 ? 1 : 3, borderWidth: 2, }; }); window._priceChart = new Chart(ctx, { type: 'line', data: { labels: allDates, datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'top', labels: { usePointStyle: true, padding: 15 } }, tooltip: { callbacks: { label: c => c.dataset.label + ': $' + (c.parsed.y != null ? c.parsed.y.toFixed(2) : 'N/A') } } }, scales: { x: { title: { display: true, text: 'Date' }, ticks: { maxTicksLimit: 15, maxRotation: 45 } }, y: { title: { display: true, text: 'Price ($)' }, beginAtZero: false, ticks: { callback: v => '$' + v.toFixed(2) } } } } }); } function renderPriceStats(data) { const monthly = data.monthly || []; const stores = Object.keys(data.products || {}); const container = document.getElementById('priceStats'); if (!monthly.length) { container.innerHTML = '

📊 Building price history. First data collected today.

'; return; } // Current prices summary let html = '
'; for (const store of stores) { const info = data.products[store]; if (!info) continue; const storeClass = 'store-' + store.toLowerCase().replace(/\s/g, ''); html += `
`; html += `${store}`; html += `
$${info.current_price.toFixed(2)}
`; html += `
`; } html += '
'; // Monthly averages table html += '

Monthly Averages & Trend

'; html += ''; for (const s of stores) html += ''; html += ''; for (let mi = 0; mi < monthly.length; mi++) { const m = monthly[mi]; html += ''; let currentAvg = 0, cc = 0; for (const s of stores) { const p = m[s]; html += ''; if (p != null) { currentAvg += p; cc++; } } currentAvg = cc ? currentAvg / cc : null; if (mi > 0 && currentAvg != null) { const prev = monthly[mi - 1]; let prevAvg = 0, pc = 0; for (const s of stores) { if (prev[s] != null) { prevAvg += prev[s]; pc++; } } prevAvg = pc ? prevAvg / pc : null; if (prevAvg != null && prevAvg > 0) { const pct = ((currentAvg - prevAvg) / prevAvg * 100).toFixed(1); if (pct > 0.05) html += ''; else if (pct < -0.05) html += ''; else html += ''; } else { html += ''; } } else { html += ''; } html += ''; } html += '
Month' + s + 'Avg Trend
' + m.month + '' + (p != null ? '$' + p.toFixed(2) : '-') + '↑ +' + pct + '%↓ ' + pct + '%→ 0%--
'; container.innerHTML = html; }