/** * Shopping List Manager - Custom Lovelace Card * * Uses proper Home Assistant WebSocket API * Version: 2.1.0 - Fixed syntax errors */ // Category definitions const CATEGORIES = [ { id: "fruitveg", emoji: "๐Ÿฅฌ", name: "Fruit & Vegetables", order: 1 }, { id: "meat", emoji: "๐Ÿฅฉ", name: "Meat, Poultry & Seafood", order: 2 }, { id: "fridge", emoji: "๐Ÿฅ›", name: "Fridge, Deli & Eggs", order: 3 }, { id: "bakery", emoji: "๐Ÿฅ–", name: "Bakery", order: 4 }, { id: "frozen", emoji: "๐ŸงŠ", name: "Frozen", order: 5 }, { id: "pantry", emoji: "๐Ÿฅซ", name: "Pantry", order: 6 }, { id: "drinks", emoji: "โ˜•", name: "Hot & Cold Drinks", order: 7 }, { id: "alcohol", emoji: "๐Ÿบ", name: "Beer, Wine & Cider", order: 8 }, { id: "health", emoji: "๐Ÿงด", name: "Health & Body", order: 9 }, { id: "baby", emoji: "๐Ÿผ", name: "Baby & Toddler", order: 10 }, { id: "pets", emoji: "๐Ÿพ", name: "Pets", order: 11 }, { id: "household", emoji: "๐Ÿงน", name: "Household & Cleaning", order: 12 }, { id: "snacks", emoji: "๐Ÿซ", name: "Snacks, Treats & Easy Meals", order: 13 }, { id: "other", emoji: "๐Ÿ“ฆ", name: "Other", order: 99 } ]; // Create category lookup map const CATEGORY_MAP = CATEGORIES.reduce((map, cat) => { map[cat.id] = cat; return map; }, {}); class ShoppingListCard extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); // State this._hass = null; this._config = null; this._products = {}; this._activeList = {}; this._searchQuery = ''; this._pollInterval = null; this._isLoading = true; this._sortBy = 'category'; // 'category' or 'alphabet' this._selectedCategory = null; // null = show all this._searchDebounceTimer = null; this._localImageCache = {}; // Cache for local image lookups this._imageListCache = null; // Cached directory listing from /local/images/shopping_list_manager/ this._cardSize = 'small'; // 'small' or 'large' - detected from card width // Settings (load from localStorage or defaults) this._settings = this._loadSettings(); } /** * Load settings from localStorage */ _loadSettings() { // Baseline defaults const defaults = { haptics: 'medium', productsPerRow: '3', layout: 'grid', hideCompleted: false, compactHeaders: false, }; // Layer 1: read from the card config (this is what HA restores // from the persisted YAML on every page load) if (this._config) { if (this._config.haptics) defaults.haptics = this._config.haptics; if (this._config.products_per_row) defaults.productsPerRow = String(this._config.products_per_row); if (this._config.layout) defaults.layout = this._config.layout; if (this._config.hide_completed !== undefined) defaults.hideCompleted = this._config.hide_completed; if (this._config.compact_headers !== undefined) defaults.compactHeaders = this._config.compact_headers; } // Layer 2: localStorage ALWAYS WINS - cog settings are the source of truth // This keeps settings persistent and instant without needing YAML edits const key = this._settingsKey || 'shopping_list_settings_default'; const saved = localStorage.getItem(key); if (saved) { try { return { ...defaults, ...JSON.parse(saved) }; } catch (e) { console.error('Failed to load settings:', e); } } return defaults; } /** * Save settings to localStorage */ _saveSettings() { // Persist to localStorage - this is the source of truth for settings const key = this._settingsKey || 'shopping_list_settings_default'; localStorage.setItem(key, JSON.stringify(this._settings)); } set hass(hass) { const oldHass = this._hass; this._hass = hass; // Load data when hass is first set if (!oldHass && hass) { this._loadData(); this._startPolling(); } } setConfig(config) { this._config = { ...config }; // shallow-copy: HA 2026+ freezes the original // Derive a unique settings key for THIS card instance so each card on // the dashboard keeps its own independent settings in localStorage. // Set `card_id` in YAML for an explicit stable label; otherwise title is used. const id = (config.card_id || config.title || 'shopping_list').toString().trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'); this._settingsKey = `shopping_list_settings_${id}`; // Re-load settings now that we have the correct key this._settings = this._loadSettings(); } /** * Load data using Home Assistant's connection.sendMessagePromise */ /** * Compare two objects for actual data changes (ignoring order and timestamps) */ _hasDataChanged(oldData, newData) { if (!oldData && !newData) return false; if (!oldData || !newData) return true; const oldKeys = Object.keys(oldData).sort(); const newKeys = Object.keys(newData).sort(); // Different number of items = changed if (oldKeys.length !== newKeys.length) return true; // Check if keys are different if (JSON.stringify(oldKeys) !== JSON.stringify(newKeys)) return true; // Check if values are different (only compare qty, name, category) for (const key of oldKeys) { const oldItem = oldData[key]; const newItem = newData[key]; // For active list, only compare qty if (oldItem.qty !== undefined && newItem.qty !== undefined) { if (oldItem.qty !== newItem.qty) return true; } // For products, compare name, category, and image else { if (oldItem.name !== newItem.name || oldItem.category !== newItem.category || oldItem.image !== newItem.image) { return true; } } } return false; } async _loadData() { if (!this._hass || !this._hass.connection) { return; } try { const [products, activeList] = await Promise.all([ this._hass.connection.sendMessagePromise({ type: 'shopping_list_manager/get_products' }), this._hass.connection.sendMessagePromise({ type: 'shopping_list_manager/get_active' }) ]); // Check if data actually changed before re-rendering const productsChanged = this._hasDataChanged(this._products, products || {}); const activeChanged = this._hasDataChanged(this._activeList, activeList || {}); const isFirstLoad = this._isLoading; this._products = products || {}; this._activeList = activeList || {}; this._isLoading = false; // Render on first load or if data changed if (isFirstLoad || productsChanged || activeChanged) { // On first load, do full render. On updates, just update content if (isFirstLoad) { this._render(); } else { this._updateContent(); } } else { } } catch (error) { console.error('Failed to load shopping list data:', error); this._isLoading = false; // Only update content on error, don't rebuild entire UI if (this.shadowRoot.querySelector('.card-content')) { this._updateContent(); } else { this._render(); } } } /** * Poll for updates every 10 seconds (only when page is visible) */ _startPolling() { if (!this._hass || this._pollInterval) { return; } this._pollInterval = setInterval(() => { // Only poll if page is visible and user isn't actively typing if (this._hass && this._hass.connection && !document.hidden) { this._loadData(); } }, 3000); // 3 seconds // Also poll when page becomes visible again this._visibilityHandler = () => { if (!document.hidden && this._hass && this._hass.connection) { this._loadData(); } }; document.addEventListener('visibilitychange', this._visibilityHandler); } /** * Fetch and cache the directory listing from the shopping_list_manager image folder. * HA's static file server returns an HTML page with links for each file. */ async _fetchImageList() { if (this._imageListCache !== null) return this._imageListCache; const dir = '/local/images/shopping_list_manager/'; try { const res = await fetch(dir); if (!res.ok) { console.warn('[ShoppingList] Image directory fetch returned', res.status, 'โ€” falling back to direct-guess mode'); this._imageListCache = []; return []; } const html = await res.text(); console.log('[ShoppingList] Raw directory HTML length:', html.length); // Extract href values from 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'}

Enter URL (http://...) or emoji. Leave empty for ๐Ÿ›’
${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('')}
Hide Completed
Hide products with quantity 0
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 ? `
${cat.name}
` : `
${cat.emoji}${cat.name}
`; 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 ? `${product.name} ` : `
${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 });