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
+ ? ``
+ : ``;
+ 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
+});