🛒
Search for a product to compare prices across SuperMax, Econo and Amigo
Searching all stores...
Stores:
SuperMax
Econo
Amigo
Sort:
Grouped by Store
Price: Low → High
Price: High → Low
Name A-Z
📝
Your shopping list is empty
Search for products and add them to your list
Product
Store
Unit Price
Qty
Subtotal
// ── 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) + '
' : ''}
${inList ? '✓ In List' : '+ Add to List'}
📈
`;
}
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 = '';
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 += ``;
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 += 'Month ';
for (const s of stores) html += '' + s + ' ';
html += 'Avg Trend ';
for (let mi = 0; mi < monthly.length; mi++) {
const m = monthly[mi];
html += '' + m.month + ' ';
let currentAvg = 0, cc = 0;
for (const s of stores) {
const p = m[s];
html += '' + (p != null ? '$' + p.toFixed(2) : '-') + ' ';
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 += '↑ +' + pct + '% ';
else if (pct < -0.05) html += '↓ ' + pct + '% ';
else html += '→ 0% ';
} else {
html += '- ';
}
} else {
html += '- ';
}
html += ' ';
}
html += '
';
container.innerHTML = html;
}