tags
const matches = [...html.matchAll(/href="([^"]+)"/g)];
const imageExts = /\.(png|jpg|jpeg|gif|svg|webp)$/i;
this._imageListCache = matches
.map(m => {
// Normalise: strip any leading path so we keep just the filename
// e.g. "/local/images/shopping_list_manager/milk.png" โ "milk.png"
let h = m[1];
const lastSlash = h.lastIndexOf('/');
if (lastSlash !== -1) h = h.substring(lastSlash + 1);
return h;
})
.filter(h => imageExts.test(h));
console.log('[ShoppingList] Parsed image list (' + this._imageListCache.length + ' files):', this._imageListCache);
} catch (e) {
console.warn('[ShoppingList] Could not fetch image list:', e, 'โ falling back to direct-guess mode');
this._imageListCache = [];
}
return this._imageListCache;
}
/**
* Score a single filename against the product name.
* Returns 0-100. Higher = better match.
*
* Strategy (order of priority):
* 100 โ exact stem match "Milk" vs "milk.png"
* 80 โ stem starts-with product "Milk" vs "milk_whole.png"
* 70 โ product starts-with stem "Whole milk" vs "milk.png" (rare but possible)
* 50 โ product contains stem "Semi skimmed milk" vs "milk.png"
* 40 โ stem contains product "milk_chocolate" vs "milk" โ product is a substring
* 20 โ any shared token (word) "organic_milk" vs "milk_2litre"
* 0 โ no overlap
*/
_scoreImage(productName, filename) {
// Strip extension and path, normalise
const stem = filename.replace(/\.[^.]+$/, '').toLowerCase().replace(/[-]/g, '_');
const prod = productName.toLowerCase().trim().replace(/[\s-]+/g, '_');
if (stem === prod) return 100;
if (stem.startsWith(prod)) return 80;
if (prod.startsWith(stem)) return 70;
if (prod.includes(stem)) return 50;
if (stem.includes(prod)) return 40;
// Token-level overlap
const stemTokens = new Set(stem.split('_').filter(Boolean));
const prodTokens = new Set(prod.split('_').filter(Boolean));
for (const t of prodTokens) {
if (stemTokens.has(t)) return 20;
}
return 0;
}
/**
* Find the best-matching local image for a product name.
* Fetches the directory listing once, then scores every file.
* Results are cached per product name for the session.
*/
/**
* Try to load a single URL; resolves true/false within 2 s.
*/
_testImageUrl(url) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = url;
setTimeout(() => resolve(false), 2000);
});
}
/**
* Direct-guess fallback: try the product name (and common
* singular/plural tweaks) with every extension directly.
* Returns the first URL that loads, or null.
*/
async _guessImageDirect(productName) {
const dir = '/local/images/shopping_list_manager/';
const base = productName.toLowerCase().trim().replace(/[\s-]+/g, '_');
const exts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'];
// Build candidate stems: exact, +s, -s, -es, -iesโy
const stems = [base];
if (!base.endsWith('s')) stems.push(base + 's'); // carrot โ carrots
if (base.endsWith('s')) stems.push(base.slice(0,-1)); // carrots โ carrot
if (base.endsWith('es')) stems.push(base.slice(0,-2)); // tomatoes โ tomat (covered by -s too)
if (base.endsWith('ies')) stems.push(base.slice(0,-3) + 'y'); // berries โ berry
for (const stem of stems) {
for (const ext of exts) {
const url = `${dir}${stem}.${ext}`;
if (await this._testImageUrl(url)) {
console.log('[ShoppingList] Direct-guess hit:', url);
return url;
}
}
}
return null;
}
async _findLocalImage(productName) {
const cacheKey = productName.toLowerCase().trim();
if (this._localImageCache[cacheKey] !== undefined) {
return this._localImageCache[cacheKey];
}
const dir = '/local/images/shopping_list_manager/';
const files = await this._fetchImageList();
let bestScore = 0;
let bestFile = null;
// --- Pass 1: score against the directory listing (if we got one) ---
if (files.length > 0) {
for (const file of files) {
const score = this._scoreImage(productName, file);
if (score > bestScore) {
bestScore = score;
bestFile = file;
if (score === 100) break;
}
}
console.log('[ShoppingList] Best listing match for "' + productName + '":', bestFile, 'score=' + bestScore);
}
// Accept listing match if score >= 20
if (bestScore >= 20 && bestFile) {
const result = `${dir}${bestFile}`;
this._localImageCache[cacheKey] = result;
return result;
}
// --- Pass 2: direct HTTP guess (works even when listing is empty/404) ---
console.log('[ShoppingList] Listing empty or no match โ trying direct guess for "' + productName + '"');
const guessed = await this._guessImageDirect(productName);
this._localImageCache[cacheKey] = guessed;
return guessed;
}
/**
* Search for appropriate emoji based on product name
*/
_searchEmoji(productName) {
const name = productName.toLowerCase().trim();
// Common food and grocery emojis mapped to keywords
const emojiMap = {
// Fruits
'apple': '๐', 'banana': '๐', 'orange': '๐', 'lemon': '๐', 'lime': '๐',
'watermelon': '๐', 'grapes': '๐', 'grape': '๐', 'strawberry': '๐', 'strawberries': '๐',
'melon': '๐', 'cherry': '๐', 'cherries': '๐', 'peach': '๐', 'pear': '๐',
'pineapple': '๐', 'kiwi': '๐ฅ', 'avocado': '๐ฅ', 'mango': '๐ฅญ', 'coconut': '๐ฅฅ',
// Vegetables
'tomato': '๐
', 'tomatoes': '๐
', 'cucumber': '๐ฅ', 'carrot': '๐ฅ', 'carrots': '๐ฅ',
'potato': '๐ฅ', 'potatoes': '๐ฅ', 'corn': '๐ฝ', 'pepper': '๐ซ', 'peppers': '๐ซ',
'broccoli': '๐ฅฆ', 'lettuce': '๐ฅฌ', 'salad': '๐ฅ', 'onion': '๐ง
', 'onions': '๐ง
',
'garlic': '๐ง', 'mushroom': '๐', 'mushrooms': '๐', 'eggplant': '๐', 'aubergine': '๐',
// Meat & Protein
'meat': '๐ฅฉ', 'beef': '๐ฅฉ', 'steak': '๐ฅฉ', 'chicken': '๐', 'poultry': '๐',
'bacon': '๐ฅ', 'pork': '๐ฅ', 'ham': '๐', 'sausage': '๐ญ', 'egg': '๐ฅ', 'eggs': '๐ฅ',
'fish': '๐', 'salmon': '๐', 'tuna': '๐', 'shrimp': '๐ฆ', 'prawn': '๐ฆ',
// Dairy
'milk': '๐ฅ', 'cheese': '๐ง', 'butter': '๐ง', 'yogurt': '๐ฅ', 'yoghurt': '๐ฅ',
'cream': '๐ฅ', 'ice cream': '๐ฆ', 'icecream': '๐ฆ',
// Bakery
'bread': '๐', 'baguette': '๐ฅ', 'croissant': '๐ฅ', 'bagel': '๐ฅฏ', 'pretzel': '๐ฅจ',
'roll': '๐ฅ', 'rolls': '๐ฅ', 'bun': '๐', 'buns': '๐', 'cake': '๐', 'cookie': '๐ช',
'cookies': '๐ช', 'donut': '๐ฉ', 'doughnut': '๐ฉ', 'pie': '๐ฅง', 'muffin': '๐ง',
// Pantry
'pasta': '๐', 'spaghetti': '๐', 'rice': '๐', 'noodles': '๐', 'ramen': '๐',
'pizza': '๐', 'burger': '๐', 'sandwich': '๐ฅช', 'taco': '๐ฎ', 'burrito': '๐ฏ',
'soup': '๐ฒ', 'stew': '๐ฒ', 'can': '๐ฅซ', 'canned': '๐ฅซ', 'jar': '๐ซ',
// Snacks & Sweets
'chocolate': '๐ซ', 'candy': '๐ฌ', 'lollipop': '๐ญ', 'chips': '๐', 'crisps': '๐',
'popcorn': '๐ฟ', 'nuts': '๐ฅ', 'peanut': '๐ฅ', 'honey': '๐ฏ',
// Drinks
'coffee': 'โ', 'tea': '๐ต', 'beer': '๐บ', 'wine': '๐ท', 'champagne': '๐พ',
'juice': '๐ง', 'soda': '๐ฅค', 'water': '๐ง', 'bottle': '๐พ',
// Condiments
'salt': '๐ง', 'pepper': '๐ซ', 'oil': '๐ซ', 'vinegar': '๐ซ', 'sauce': '๐ฅซ',
'ketchup': '๐
', 'mustard': '๐ญ', 'mayo': '๐ฅ', 'mayonnaise': '๐ฅ',
// Household
'toilet': '๐งป', 'paper': '๐งป', 'soap': '๐งผ', 'shampoo': '๐งด', 'detergent': '๐งด',
'cleaning': '๐งน', 'bleach': '๐งด', 'sponge': '๐งฝ', 'trash': '๐๏ธ', 'bin': '๐๏ธ',
// Baby
'baby': '๐ถ', 'diaper': '๐ผ', 'nappy': '๐ผ', 'formula': '๐ผ', 'wipes': '๐งป',
// Pet
'dog': '๐', 'cat': '๐', 'pet': '๐พ', 'food': '๐พ'
};
// Try exact match first
if (emojiMap[name]) {
return emojiMap[name];
}
// Try partial matches (check if any keyword is in the product name)
for (const [keyword, emoji] of Object.entries(emojiMap)) {
if (name.includes(keyword)) {
return emoji;
}
}
// Default fallback
return '๐';
}
/**
* Add a new product - opens the unified dialog in "new" mode
*/
async _addNewProduct(name) {
const key = this._generateKey(name);
if (this._products[key]) {
alert('Product already exists');
return;
}
// Auto-search: try local image first, then fall back to emoji
const localImage = await this._findLocalImage(name);
const autoImage = localImage || this._searchEmoji(name);
// Open the same dialog used for editing, but in "new" mode
const result = await this._showEditDialog(null, { name, image: autoImage });
// Always clear search after dialog closes (cancel or save)
this._searchQuery = '';
const _sb = this.shadowRoot.querySelector('.search-bar');
if (_sb) _sb.value = '';
const _sc = this.shadowRoot.querySelector('.search-clear');
if (_sc) _sc.style.display = 'none';
const _addBtn = this.shadowRoot.querySelector('.settings-btn');
if (_addBtn) { _addBtn.textContent = 'โ๏ธ'; _addBtn.title = 'Settings'; }
this._updateContent();
if (!result || result.action !== 'save') return;
try {
await this._hass.connection.sendMessagePromise({
type: 'shopping_list_manager/add_product',
key: key,
name: result.name,
category: result.category,
unit: 'pcs',
image: result.image || ''
});
await this._hass.connection.sendMessagePromise({
type: 'shopping_list_manager/set_qty',
key: key,
qty: 1
});
await this._loadData();
this._hapticFeedback();
} catch (error) {
console.error('Failed to add product:', error);
alert('Failed to add product: ' + error.message);
}
}
/**
* Show product edit dialog
*/
async _showEditDialog(productKey, newProductDefaults = null) {
// "new" mode when productKey is null and defaults are passed
const isNew = !productKey && newProductDefaults;
const product = isNew
? { name: newProductDefaults.name || '', image: newProductDefaults.image || '', category: 'other' }
: this._products[productKey];
if (!product) return;
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
isolation: isolate;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: var(--card-background-color);
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
`;
const currentCategory = CATEGORY_MAP[product.category] || CATEGORY_MAP['other'];
dialog.innerHTML = `
${isNew ? 'New Product' : 'Edit Product'}
${isNew ? '' : ``}
`;
modal.appendChild(dialog);
document.body.appendChild(modal);
// Save button
dialog.querySelector('.save-btn').addEventListener('click', () => {
const newName = dialog.querySelector('.edit-name').value.trim();
const newImage = dialog.querySelector('.edit-image').value.trim();
const newCategory = dialog.querySelector('.edit-category').value;
if (!newName) {
alert('Product name cannot be empty');
return;
}
document.body.removeChild(modal);
resolve({ action: 'save', name: newName, category: newCategory, image: newImage });
});
// Delete button (only present in edit mode)
const deleteBtn = dialog.querySelector('.delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
if (confirm(`Delete "${product.name}"?`)) {
document.body.removeChild(modal);
resolve({ action: 'delete' });
}
});
}
// Cancel button
dialog.querySelector('.cancel-btn').addEventListener('click', () => {
document.body.removeChild(modal);
resolve(null);
});
// Backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
resolve(null);
}
});
});
}
/**
* Edit a product
*/
async _editProduct(productKey) {
const result = await this._showEditDialog(productKey);
if (!result) return;
try {
if (result.action === 'delete') {
// Delete product
await this._hass.connection.sendMessagePromise({
type: 'shopping_list_manager/delete_product',
key: productKey
});
// Reload data
await this._loadData();
this._hapticFeedback();
} else if (result.action === 'save') {
// Update product
await this._hass.connection.sendMessagePromise({
type: 'shopping_list_manager/add_product',
key: productKey,
name: result.name,
category: result.category,
unit: 'pcs',
image: result.image || ''
});
// Reload data
await this._loadData();
this._hapticFeedback();
}
} catch (error) {
console.error('Failed to edit product:', error);
alert('Failed to edit product: ' + error.message);
}
}
/**
* Set quantity for a product
*/
async _setQuantity(productKey, qty) {
const currentQty = this._activeList[productKey]?.qty || 0;
// Optimistic update
if (qty === 0) {
delete this._activeList[productKey];
} else {
this._activeList[productKey] = { qty };
}
this._render();
this._hapticFeedback();
try {
await this._hass.connection.sendMessagePromise({
type: 'shopping_list_manager/set_qty',
key: productKey,
qty: qty
});
} catch (error) {
console.error('Failed to set quantity:', error);
// Revert on error
if (currentQty === 0) {
delete this._activeList[productKey];
} else {
this._activeList[productKey] = { qty: currentQty };
}
this._render();
alert('Failed to update quantity');
}
}
/**
* Toggle product on/off list
*/
async _toggleProduct(productKey) {
const currentQty = this._activeList[productKey]?.qty || 0;
const newQty = currentQty > 0 ? 0 : 1;
await this._setQuantity(productKey, newQty);
}
/**
* Increment quantity
*/
async _incrementProduct(productKey) {
const currentQty = this._activeList[productKey]?.qty || 0;
await this._setQuantity(productKey, currentQty + 1);
}
/**
* Decrement quantity
*/
async _decrementProduct(productKey) {
const currentQty = this._activeList[productKey]?.qty || 0;
if (currentQty > 0) {
await this._setQuantity(productKey, currentQty - 1);
}
}
/**
* Generate product key from name
*/
_generateKey(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Haptic feedback based on settings
*/
_hapticFeedback(intensity = 'medium') {
if (this._settings.haptics === 'off') return;
if ('vibrate' in navigator) {
const patterns = {
low: 5,
medium: 10,
high: 20
};
const duration = patterns[this._settings.haptics] || patterns.medium;
navigator.vibrate(duration);
}
}
/**
* Show settings modal
*/
_showSettings() {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 100000;
isolation: isolate;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: var(--card-background-color);
border-radius: 16px 16px 0 0;
padding: 12px 24px 32px 24px;
width: 100%;
max-width: 480px;
max-height: 70vh;
overflow-y: auto;
position: relative;
`;
dialog.innerHTML = `
Settings
${['off', 'low', 'medium', 'high'].map(level => `
`).join('')}
${['auto', '2', '3', '4', '5'].map(count => `
`).join('')}
Compact Headers
Show category names only (no emoji)
`;
modal.appendChild(dialog);
document.body.appendChild(modal);
// Haptics buttons
dialog.querySelectorAll('.haptic-btn').forEach(btn => {
btn.addEventListener('click', () => {
this._settings.haptics = btn.dataset.level;
this._saveSettings();
// Update visual selection
dialog.querySelectorAll('.haptic-btn').forEach(b => {
b.style.borderColor = 'var(--divider-color)';
b.style.background = 'var(--card-background-color)';
});
btn.style.borderColor = 'var(--primary-color)';
btn.style.background = 'var(--primary-color)';
// Test vibration
this._hapticFeedback();
});
});
// Products per row buttons
dialog.querySelectorAll('.perrow-btn').forEach(btn => {
btn.addEventListener('click', () => {
this._settings.productsPerRow = btn.dataset.count;
this._saveSettings();
this._updateContent(); // apply grid change immediately
// Update visual selection
dialog.querySelectorAll('.perrow-btn').forEach(b => {
b.style.borderColor = 'var(--divider-color)';
b.style.background = 'var(--card-background-color)';
});
btn.style.borderColor = 'var(--primary-color)';
btn.style.background = 'var(--primary-color)';
this._hapticFeedback();
});
});
// Hide completed toggle
const hideCompletedToggle = dialog.querySelector('.hide-completed-toggle');
if (hideCompletedToggle) {
hideCompletedToggle.addEventListener('change', (e) => {
this._settings.hideCompleted = e.target.checked;
this._saveSettings();
// Update toggle visual state
const toggle = e.target.parentElement;
const slider = toggle.querySelectorAll('span')[0];
const knob = toggle.querySelectorAll('span')[1];
slider.style.backgroundColor = e.target.checked ? 'var(--primary-color)' : 'var(--divider-color)';
knob.style.left = e.target.checked ? '23px' : '3px';
this._updateContent();
this._hapticFeedback();
});
}
// Compact headers toggle
const compactHeadersToggle = dialog.querySelector('.compact-headers-toggle');
if (compactHeadersToggle) {
compactHeadersToggle.addEventListener('change', (e) => {
this._settings.compactHeaders = e.target.checked;
this._saveSettings();
// Update toggle visual state
const toggle = e.target.parentElement;
const slider = toggle.querySelectorAll('span')[0];
const knob = toggle.querySelectorAll('span')[1];
slider.style.backgroundColor = e.target.checked ? 'var(--primary-color)' : 'var(--divider-color)';
knob.style.left = e.target.checked ? '23px' : '3px';
this._updateContent();
this._hapticFeedback();
});
}
// Layout buttons
dialog.querySelectorAll('.layout-btn').forEach(btn => {
btn.addEventListener('click', () => {
this._settings.layout = btn.dataset.layout;
this._saveSettings();
this._updateContent(); // switch gridโlist immediately
// Update visual selection
dialog.querySelectorAll('.layout-btn').forEach(b => {
b.style.borderColor = 'var(--divider-color)';
b.style.background = 'var(--card-background-color)';
});
btn.style.borderColor = 'var(--primary-color)';
btn.style.background = 'var(--primary-color)';
this._hapticFeedback();
});
});
// Close button (Done)
dialog.querySelector('.close-btn').addEventListener('click', () => {
document.body.removeChild(modal);
this._updateContent();
resolve();
});
// โ top-right close button
dialog.querySelector('.close-x-btn').addEventListener('click', () => {
document.body.removeChild(modal);
this._updateContent();
resolve();
});
// Backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
this._updateContent();
resolve();
}
});
});
}
/**
* Fuzzy search - matches even with typos or partial matches
*/
_fuzzyMatch(text, query) {
text = text.toLowerCase();
query = query.toLowerCase();
// Exact match or substring
if (text.includes(query)) {
return true;
}
// Fuzzy match - allows for missing characters
let queryIndex = 0;
for (let i = 0; i < text.length && queryIndex < query.length; i++) {
if (text[i] === query[queryIndex]) {
queryIndex++;
}
}
return queryIndex === query.length;
}
/**
* Filter products by search and split into active/inactive
*/
_getFilteredProducts() {
let products = Object.values(this._products);
// Filter by search query with fuzzy matching
if (this._searchQuery) {
const query = this._searchQuery.toLowerCase();
products = products.filter(product =>
this._fuzzyMatch(product.name, query)
);
}
// Filter by selected category
if (this._selectedCategory) {
products = products.filter(product =>
product.category === this._selectedCategory
);
}
// Sort products
if (this._sortBy === 'category') {
// Sort by category order, then by name
products.sort((a, b) => {
const catA = CATEGORY_MAP[a.category] || CATEGORY_MAP['other'];
const catB = CATEGORY_MAP[b.category] || CATEGORY_MAP['other'];
if (catA.order !== catB.order) {
return catA.order - catB.order;
}
return a.name.localeCompare(b.name);
});
} else {
// Alphabetical sort
products.sort((a, b) => a.name.localeCompare(b.name));
}
return products;
}
/**
* Get active products (in shopping list)
*/
_getActiveProducts() {
const filtered = this._getFilteredProducts();
return filtered.filter(product => {
const qty = this._activeList[product.key]?.qty || 0;
return qty > 0;
});
}
/**
* Get inactive products (not in shopping list)
*/
_getInactiveProducts() {
const filtered = this._getFilteredProducts();
return filtered.filter(product => {
const qty = this._activeList[product.key]?.qty || 0;
return qty === 0;
});
}
/**
* Check if should show "Add New" button
*/
_shouldShowAddNew() {
if (!this._searchQuery || this._searchQuery.length < 2) {
return false;
}
const query = this._searchQuery.toLowerCase();
const exactMatch = Object.values(this._products).some(
product => product.name.toLowerCase() === query
);
return !exactMatch;
}
/**
* Render the card
*/
_render() {
// Only do initial render if the structure doesn't exist
if (!this.shadowRoot.querySelector('.card-content')) {
this._initialRender();
return;
}
// Otherwise just update the content area
this._updateContent();
}
/**
* Initial render - creates the persistent structure
*/
_initialRender() {
this.shadowRoot.innerHTML = `
`;
this._attachPersistentListeners();
this._updateContent();
}
/**
* Update only the content area (not search bar)
*/
_updateContent() {
const contentArea = this.shadowRoot.querySelector('.content-area');
if (!contentArea) return;
if (this._isLoading) {
contentArea.innerHTML = 'Loading...
';
return;
}
const activeProducts = this._getActiveProducts();
const inactiveProducts = this._getInactiveProducts();
let html = '';
// Show active products first
if (activeProducts.length > 0) {
if (this._sortBy === 'category') {
html += this._renderProductsByCategory(activeProducts, false);
} else {
const containerClass = this._settings.layout === 'list' ? 'product-list' : 'product-grid';
html += ``;
html += activeProducts.map(product => this._renderProductTile(product)).join('');
html += '
';
}
}
// Show inactive products in "Recently Used" section (unless hidden)
if (inactiveProducts.length > 0 && !this._searchQuery && !this._settings.hideCompleted) {
html += `
Recently Used
${this._sortBy === 'category' ?
this._renderProductsByCategory(inactiveProducts, true) :
`
${inactiveProducts.map(product => this._renderProductTile(product)).join('')}
`
}
`;
}
// Empty state
if (activeProducts.length === 0 && inactiveProducts.length === 0) {
html += `
${this._searchQuery ? 'No products found' : 'No products yet. Search to add your first product!'}
`;
}
contentArea.innerHTML = html;
// Stamp live grid columns onto every .product-grid so the setting
// takes effect immediately without needing a full re-render.
const gridCols = this._settings.productsPerRow === 'auto'
? 'repeat(auto-fill, minmax(120px, 1fr))'
: `repeat(${this._settings.productsPerRow}, 1fr)`;
contentArea.querySelectorAll('.product-grid').forEach(g => {
g.style.gridTemplateColumns = gridCols;
});
this._attachContentListeners();
}
/**
* Render products by category (helper for active/inactive)
*/
_renderProductsByCategory(products, isRecentlyUsed) {
const containerClass = this._settings.layout === 'list' ? 'product-list' : 'product-grid';
const productsByCategory = {};
products.forEach(product => {
const categoryId = product.category || 'other';
if (!productsByCategory[categoryId]) {
productsByCategory[categoryId] = [];
}
productsByCategory[categoryId].push(product);
});
return CATEGORIES
.filter(cat => productsByCategory[cat.id] && productsByCategory[cat.id].length > 0)
.map(cat => {
const categoryProducts = productsByCategory[cat.id];
const headerHtml = this._settings.compactHeaders
? ``
: ``;
return `
${headerHtml}
${categoryProducts.map(product => this._renderProductTile(product)).join('')}
`;
}).join('');
}
/**
* Attach listeners that persist (search bar, sort buttons)
*/
_attachPersistentListeners() {
const searchBar = this.shadowRoot.querySelector('.search-bar');
const searchClear = this.shadowRoot.querySelector('.search-clear');
const settingsBtn = this.shadowRoot.querySelector('.settings-btn');
if (searchBar) {
searchBar.addEventListener('input', (e) => {
this._searchQuery = e.target.value;
// โ clear button: show/hide based on text
if (searchClear) {
searchClear.style.display = this._searchQuery.length > 0 ? 'block' : 'none';
}
// Settings/Add button: swap โ๏ธ โ โ based on whether "add new" applies
if (settingsBtn) {
if (this._searchQuery.length > 0 && this._shouldShowAddNew()) {
settingsBtn.textContent = 'โ';
settingsBtn.title = 'Add new product';
} else {
settingsBtn.textContent = 'โ๏ธ';
settingsBtn.title = 'Settings';
}
}
this._updateContent();
});
}
// โ clear search
if (searchClear) {
searchClear.addEventListener('click', () => {
this._searchQuery = '';
searchBar.value = '';
searchClear.style.display = 'none';
if (settingsBtn) { settingsBtn.textContent = 'โ๏ธ'; settingsBtn.title = 'Settings'; }
searchBar.focus();
this._updateContent();
this._hapticFeedback();
});
}
// Settings / Add button (toggles based on search state)
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
if (settingsBtn.textContent === 'โ') {
this._addNewProduct(this._searchQuery);
} else {
this._showSettings();
}
this._hapticFeedback();
});
}
const sortButtons = this.shadowRoot.querySelectorAll('.control-btn[data-sort]');
sortButtons.forEach(btn => {
btn.addEventListener('click', () => {
this._sortBy = btn.dataset.sort;
// Update button states
sortButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this._updateContent();
this._hapticFeedback();
});
});
}
/**
* Attach listeners for content area (products, buttons)
*/
_attachContentListeners() {
// Product tiles - add long-press for edit
const tiles = this.shadowRoot.querySelectorAll('.product-tile');
tiles.forEach(tile => {
const key = tile.dataset.key;
let longPressTimer = null;
let longPressTriggered = false;
// Touch events for mobile long-press
tile.addEventListener('touchstart', (e) => {
longPressTriggered = false;
longPressTimer = setTimeout(() => {
longPressTriggered = true;
this._hapticFeedback();
this._editProduct(key);
}, 500); // 500ms long press
}, { passive: true });
tile.addEventListener('touchend', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
}
});
tile.addEventListener('touchmove', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
}
}, { passive: true });
// Mouse events for desktop
tile.addEventListener('mousedown', (e) => {
longPressTriggered = false;
longPressTimer = setTimeout(() => {
longPressTriggered = true;
this._editProduct(key);
}, 500);
});
tile.addEventListener('mouseup', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
}
});
tile.addEventListener('mouseleave', () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
}
});
// Right-click for edit
tile.addEventListener('contextmenu', (e) => {
e.preventDefault();
this._editProduct(key);
});
// Regular click
tile.addEventListener('click', (e) => {
// Don't toggle if it was a long press or clicking buttons
if (!longPressTriggered && !e.target.closest('.qty-button')) {
// Check if click is in bottom 30px area (where buttons would be)
const rect = tile.getBoundingClientRect();
const clickY = e.clientY - rect.top;
const isBottomArea = clickY > (rect.height - 35);
// Only toggle if not clicking in button area
if (!isBottomArea || !isActive) {
this._toggleProduct(key);
}
}
});
});
// Plus buttons
const plusButtons = this.shadowRoot.querySelectorAll('.plus-btn');
plusButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._incrementProduct(btn.dataset.key);
});
});
// Minus buttons
const minusButtons = this.shadowRoot.querySelectorAll('.minus-btn');
minusButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._decrementProduct(btn.dataset.key);
});
});
}
/**
* Render a single product tile
*/
_renderProductTile(product) {
const qty = this._activeList[product.key]?.qty || 0;
const isActive = qty > 0;
const hasImage = product.image && product.image.trim().length > 0;
// Determine if image is URL or emoji
const isUrl = hasImage && (product.image.startsWith('http') || product.image.startsWith('/'));
const displayImage = hasImage ? product.image : '๐';
return `
${product.name}
${isUrl ?
`
๐
` :
`
${displayImage}
`
}
${isActive ? `
` : `
Tap to add
`}
`;
}
disconnectedCallback() {
if (this._pollInterval) {
clearInterval(this._pollInterval);
}
if (this._visibilityHandler) {
document.removeEventListener('visibilitychange', this._visibilityHandler);
}
}
// Temporarily disabled GUI editor - use YAML configuration
// static getConfigElement() {
// return document.createElement('shopping-list-card-editor');
// }
static getStubConfig() {
return { title: 'Shopping List' };
}
getCardSize() {
return 3;
}
}
// โโ GUI Config Editor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// This is the standard HA card-editor pattern. HA renders this
// element inside its "configure card" panel automatically when the
// card exposes a static getConfigElement() method.
class ShoppingListCardEditor extends HTMLElement {
setConfig(config) {
console.log('[Editor] setConfig called, _rendered:', this._rendered, 'config:', config);
this._config = { ...(config || {}) }; // shallow-copy: HA 2026+ freezes the original
if (!this._rendered) {
console.log('[Editor] First render, calling _render()');
this._render(); // only render once, then STOP
} else {
console.log('[Editor] Already rendered, ignoring setConfig');
}
// After first render, ignore setConfig calls - the updateConfig closure
// already handles live updates by mutating this._config directly
}
set hass(hass) {
console.log('[Editor] hass setter called, _rendered:', this._rendered);
this._hass = hass;
if (!this._rendered) this._render();
}
_render() {
if (!this._config) return; // nothing to render yet
// Attach shadow DOM once, reuse on subsequent renders
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
const title = this._config.title || '';
const productsPerRow = String(this._config.products_per_row || '3');
const layout = this._config.layout || 'grid';
const haptics = this._config.haptics || 'medium';
const hideCompleted = !!this._config.hide_completed;
const compactHeaders = !!this._config.compact_headers;
this.shadowRoot.innerHTML = `
Auto
2
3
4
5
Grid
List
Off
Low
Medium
High
Hide completed items
Hides the "Recently Used" section showing products not currently on the list.
Compact headers
Show category names only (no emoji)
${this._showCodeEditor ? `${this._toYaml()}
` : ''}
`;
// โโ Set values imperatively (reliable with shadow DOM + HA components) โโ
const titleEl = this.shadowRoot.querySelector('#ed-title');
const perrowEl = this.shadowRoot.querySelector('#ed-perrow');
const layoutEl = this.shadowRoot.querySelector('#ed-layout');
const hapticsEl = this.shadowRoot.querySelector('#ed-haptics');
const hideCompletedEl = this.shadowRoot.querySelector('#ed-hide-completed');
const hideHeadersEl = this.shadowRoot.querySelector('#ed-hide-headers');
titleEl.value = title;
perrowEl.value = productsPerRow;
layoutEl.value = layout;
hapticsEl.value = haptics;
hideCompletedEl.checked = hideCompleted;
hideHeadersEl.checked = compactHeaders;
// โโ Single updateConfig closure โ mirrors your working pattern โโ
const updateConfig = () => {
console.log('[Editor] updateConfig called');
this._config = {
...this._config,
title: titleEl.value || 'Shopping List',
products_per_row: perrowEl.value,
layout: layoutEl.value,
haptics: hapticsEl.value,
hide_completed: hideCompletedEl.checked,
compact_headers: hideHeadersEl.checked
};
console.log('[Editor] Dispatching config-changed:', this._config);
this.dispatchEvent(new CustomEvent('config-changed', {
detail: { config: this._config },
bubbles: true,
composed: true
}));
};
titleEl.addEventListener('input', updateConfig);
perrowEl.addEventListener('value-changed', updateConfig);
layoutEl.addEventListener('value-changed', updateConfig);
hapticsEl.addEventListener('value-changed', updateConfig);
hideCompletedEl.addEventListener('checked-changed', updateConfig);
hideHeadersEl.addEventListener('checked-changed', updateConfig);
this.shadowRoot.querySelector('#ed-code-toggle').addEventListener('click', () => {
this._showCodeEditor = !this._showCodeEditor;
this._render();
});
this._rendered = true;
}
_toYaml() {
const lines = ['type: custom:shopping-list-card'];
if (this._config.title) lines.push('title: ' + this._config.title);
if (this._config.products_per_row) lines.push('products_per_row: ' + this._config.products_per_row);
if (this._config.layout) lines.push('layout: ' + this._config.layout);
if (this._config.haptics) lines.push('haptics: ' + this._config.haptics);
if (this._config.hide_completed) lines.push('hide_completed: true');
if (this._config.compact_headers) lines.push('compact_headers: true');
return lines.join('\n');
}
}
customElements.define('shopping-list-card-editor', ShoppingListCardEditor);
customElements.define('shopping-list-card', ShoppingListCard);
window.customCards = window.customCards || [];
window.customCards.push({
type: 'shopping-list-card',
name: 'Shopping List Card',
description: 'A shopping list card with search, categories, and product images.',
preview: false
});