diff --git a/custom_components/shopping_list_manager/frontend/shopping_list_card.js b/custom_components/shopping_list_manager/frontend/shopping_list_card.js deleted file mode 100644 index 49bae09..0000000 --- a/custom_components/shopping_list_manager/frontend/shopping_list_card.js +++ /dev/null @@ -1,2202 +0,0 @@ -/** - * 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 -});