diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c117c..8b0e6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. +## [1.7.27] - 2026-05-29 + +### Added +- **HA sensor enrichment** — All HA sensor attributes that list products now include full product details: `location`, `brand`, `category`, `days_remaining`, `opened_at`, `vacuum_sealed`, `default_quantity`, `package_unit`, `product_id`, `inventory_id`. Applies to `expiring_list`, the new `expired_list`, and the new `low_stock_list`. +- **HA `expired_list` attribute** — `sensor.evershelf_overview` now exposes `expired_list` (full details for all expired items, not just a count). +- **HA `low_stock_list` attribute** — New attribute listing all items with quantity ≤ 1 with full product info. +- **HA `sensor=product` endpoint** — New `GET /api/?action=ha_sensor&sensor=product` returns the full inventory with all product details. Optional filters: `&id=N`, `&name=...`, `&location=...`. +- **Inventory edit safety guard** — Confirm dialog when saving a quantity that is unusually large for its unit (e.g. 183 conf), preventing accidental data loss from unit-confusion typos. +- **Bread shelf-life in fridge** — Opened shelf-life rules added for piadina/crescia (2 days), packaged sliced bread/bauletto (4 days), and generic bread (3 days). + +### Fixed +- **Recipe AI ingredient substitution** — Added explicit rule to both recipe prompts preventing Gemini from substituting ingredient forms (e.g. fresh tomatoes ↔ passata, fresh milk ↔ UHT ↔ cream, flour 00 ↔ wholemeal). +- **HA cron webhook payload** — Expiry alert webhook items now include full product details (brand, category, location, days_remaining, opened_at, vacuum_sealed) instead of only name/qty/unit/expiry_date. + +### Docs +- `docs/wiki/Home-Assistant.md` — Documented new `sensor=product` endpoint, full product schema table, enriched webhook payload example, and Lovelace/automation template examples using `location` and `days_remaining`. + ## [1.7.26] - 2026-05-26 ### Added diff --git a/api/cron_smart_shopping.php b/api/cron_smart_shopping.php index b2c6ae0..2a9891a 100644 --- a/api/cron_smart_shopping.php +++ b/api/cron_smart_shopping.php @@ -142,7 +142,9 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') { if (!file_exists($haFlagFile)) { $expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3')); $expiringItems = $db->query( - "SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location + "SELECT p.id AS product_id, i.id AS inventory_id, + p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, + i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days') @@ -150,13 +152,44 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') { )->fetchAll(PDO::FETCH_ASSOC); $expiredItems = $db->query( - "SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location + "SELECT p.id AS product_id, i.id AS inventory_id, + p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, + i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL AND i.expiry_date < date('now') ORDER BY i.expiry_date ASC LIMIT 10" )->fetchAll(PDO::FETCH_ASSOC); + // Normalise rows to full product format + if (!function_exists('_haFormatProduct')) { + function _haFormatProduct(array $row): array { + $daysRemaining = null; + if (!empty($row['expiry_date'])) { + $diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date'])); + $daysRemaining = (int)$diff->format('%r%a'); + } + return [ + 'product_id' => (int)($row['product_id'] ?? 0), + 'inventory_id' => (int)($row['inventory_id'] ?? 0), + 'name' => $row['name'], + 'brand' => $row['brand'] ?? null, + 'category' => $row['category'] ?? null, + 'quantity' => (float)($row['quantity'] ?? 0), + 'unit' => $row['unit'] ?? '', + 'default_quantity' => (float)($row['default_quantity'] ?? 0), + 'package_unit' => $row['package_unit'] ?? null, + 'location' => $row['location'] ?? null, + 'expiry_date' => $row['expiry_date'] ?? null, + 'days_remaining' => $daysRemaining, + 'opened_at' => $row['opened_at'] ?? null, + 'vacuum_sealed' => !empty($row['vacuum_sealed']), + ]; + } + } + $expiringItems = array_map('_haFormatProduct', $expiringItems); + $expiredItems = array_map('_haFormatProduct', $expiredItems); + if (!empty($expiringItems)) { $names = implode(', ', array_column($expiringItems, 'name')); _fireHaWebhook('expiry_alert', [ diff --git a/api/database.php b/api/database.php index 37cee13..ab02b0f 100644 --- a/api/database.php +++ b/api/database.php @@ -464,6 +464,14 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4; if (preg_match('/\baglio\b/', $n)) return 14; + // ── F.extra: Bread in fridge (opened) ────────────────────────────────── + // Thin flatbreads (piadina, crescia, tigella) get mold very quickly + if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2; + // Packaged sliced bread — preservatives help a bit + if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4; + // Generic bread / sandwich bread in fridge + if (preg_match('/\bpane\b/', $cat)) return 3; + // ── G: Fridge condiments — medium shelf-life ───────────────────────── if (preg_match('/maionese|mayo|mayon/', $n)) return 90; if (preg_match('/\bketchup\b/', $n)) return 90; diff --git a/api/index.php b/api/index.php index c7053a2..649e9aa 100644 --- a/api/index.php +++ b/api/index.php @@ -1376,12 +1376,47 @@ function _sendHaNotify(string $message, array $data = []): void { } } +/** + * Normalise a DB inventory+product row into a full product info array + * used consistently across all HA sensor attributes and webhook payloads. + */ +function _haFormatProduct(array $row): array { + $daysRemaining = null; + if (!empty($row['expiry_date'])) { + $diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date'])); + $daysRemaining = (int)$diff->format('%r%a'); + } + return [ + 'product_id' => (int)($row['product_id'] ?? 0), + 'inventory_id' => (int)($row['inventory_id'] ?? 0), + 'name' => $row['name'], + 'brand' => $row['brand'] ?? null, + 'category' => $row['category'] ?? null, + 'quantity' => (float)($row['quantity'] ?? 0), + 'unit' => $row['unit'] ?? '', + 'default_quantity' => (float)($row['default_quantity'] ?? 0), + 'package_unit' => $row['package_unit'] ?? null, + 'location' => $row['location'] ?? null, + 'expiry_date' => $row['expiry_date'] ?? null, + 'days_remaining' => $daysRemaining, + 'opened_at' => $row['opened_at'] ?? null, + 'vacuum_sealed' => !empty($row['vacuum_sealed']), + ]; +} + +/** Full product detail SQL fragment reused in all HA queries. */ +function _haProductSelect(): string { + return "p.id AS product_id, i.id AS inventory_id, + p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, + i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed"; +} + /** * HA REST sensor endpoint — returns pantry state in Home Assistant-compatible format. * Use with platform: rest in configuration.yaml. * * GET /api/?action=ha_sensor[&sensor=NAME] - * Available sensor names: expiring, expired, total, shopping + * Available sensor names: expiring, expired, total, shopping, product */ function haInventorySensor(PDO $db): void { header('Content-Type: application/json; charset=utf-8'); @@ -1390,6 +1425,38 @@ function haInventorySensor(PDO $db): void { $sensor = strtolower(trim($_GET['sensor'] ?? 'overview')); $expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3)))); + // ── sensor=product: full inventory details, optionally filtered ────────── + if ($sensor === 'product') { + try { + $invId = (int)($_GET['id'] ?? 0); + $search = trim($_GET['name'] ?? ''); + $loc = trim($_GET['location'] ?? ''); + $where = "WHERE i.quantity > 0"; + $params = []; + if ($invId > 0) { $where .= " AND i.id = ?"; $params[] = $invId; } + elseif ($search !== '') { $where .= " AND LOWER(p.name) LIKE ?"; $params[] = '%' . mb_strtolower($search, 'UTF-8') . '%'; } + if ($loc !== '') { $where .= " AND i.location = ?"; $params[] = $loc; } + $stmt = $db->prepare( + "SELECT " . _haProductSelect() . " + FROM inventory i JOIN products p ON p.id = i.product_id + $where ORDER BY p.name ASC" + ); + $stmt->execute($params); + $items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC)); + header('Content-Type: application/json; charset=utf-8'); + header('Access-Control-Allow-Origin: *'); + echo json_encode([ + 'state' => count($items), + 'items' => $items, + 'last_updated' => date('c'), + ], JSON_UNESCAPED_UNICODE); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } + return; + } + try { $expiring = (int)$db->query( "SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL @@ -1416,14 +1483,30 @@ function haInventorySensor(PDO $db): void { $shoppingCount = (int)$db->query("SELECT COUNT(*) FROM shopping_list")->fetchColumn(); } - // Expiring items details + // Expiring items details (full product info, all within $expiryDays window) $expiringItems = $db->query( - "SELECT p.name, i.quantity, p.unit, i.expiry_date - FROM inventory i - JOIN products p ON p.id = i.product_id + "SELECT " . _haProductSelect() . " + FROM inventory i JOIN products p ON p.id = i.product_id WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL - AND i.expiry_date BETWEEN date('now') AND date('now', '+7 days') - ORDER BY i.expiry_date ASC LIMIT 10" + AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days') + ORDER BY i.expiry_date ASC" + )->fetchAll(PDO::FETCH_ASSOC); + + // Expired items (full product info) + $expiredItemsList = $db->query( + "SELECT " . _haProductSelect() . " + FROM inventory i JOIN products p ON p.id = i.product_id + WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL + AND i.expiry_date < date('now') + ORDER BY i.expiry_date ASC" + )->fetchAll(PDO::FETCH_ASSOC); + + // Low-stock items (quantity <= 1 but > 0, full product info) + $lowStockItemsList = $db->query( + "SELECT " . _haProductSelect() . " + FROM inventory i JOIN products p ON p.id = i.product_id + WHERE i.quantity > 0 AND i.quantity <= 1 + ORDER BY i.quantity ASC, p.name ASC" )->fetchAll(PDO::FETCH_ASSOC); // Opened items @@ -1540,13 +1623,9 @@ function haInventorySensor(PDO $db): void { 'shopping_total' => $shoppingTotal, 'price_tracking_enabled' => $priceEnabled, 'price_currency' => $priceCurrency, - 'expiring_list' => array_map(fn($r) => [ - 'name' => $r['name'], - 'quantity' => (float)$r['quantity'], - 'unit' => $r['unit'], - 'expiry_date' => $r['expiry_date'], - 'expires_today' => $r['expiry_date'] <= date('Y-m-d', strtotime('+1 days')), - ], $expiringItems), + 'expiring_list' => array_map('_haFormatProduct', $expiringItems), + 'expired_list' => array_map('_haFormatProduct', $expiredItemsList), + 'low_stock_list' => array_map('_haFormatProduct', $lowStockItemsList), 'next_expiry_name' => !empty($expiringItems) ? $expiringItems[0]['name'] : null, 'next_expiry_date' => !empty($expiringItems) ? $expiringItems[0]['expiry_date'] : null, 'unit_of_measurement' => 'items', @@ -2528,10 +2607,12 @@ function addToInventory(PDO $db): void { return; } - // If a different unit was specified, update the product's unit + // If a different unit was specified, update the product's unit. + // NOTE: default_quantity is the PACKAGE SIZE, not the quantity being added — + // do NOT overwrite it here. It is managed via product_save / the edit form. if ($unit) { - $stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$unit, $quantity, $productId]); + $stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$unit, $productId]); } else { // Auto-set default_quantity if product has none (first add sets package size) $stmt = $db->prepare("SELECT default_quantity, unit FROM products WHERE id = ?"); @@ -2995,7 +3076,7 @@ function useFromInventory(PDO $db): void { } } - // Calculate total remaining across ALL locations + // Calculate total remaining across ALL locations (this product only) $stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0"); $stmt->execute([$productId]); $totalRemaining = round((float)($stmt->fetchColumn() ?: 0), 6); @@ -3005,8 +3086,26 @@ function useFromInventory(PDO $db): void { $stmt->execute([$productId]); $prodInfo = $stmt->fetch(); + // Also sum related products in the same shopping_name family (same unit) so that + // e.g. "Uova Sfoglia Gialla" + "Uova biologiche" are evaluated together for low stock. + $totalFamilyRemaining = $totalRemaining; + if ($prodInfo) { + $sNameKey = strtolower(trim($prodInfo['shopping_name'] ?? '')); + $prodUnit = $prodInfo['unit'] ?? ''; + if ($sNameKey !== '' && $prodUnit !== '') { + $famStmt = $db->prepare(" + SELECT SUM(i.quantity) + FROM inventory i + JOIN products p ON i.product_id = p.id + WHERE LOWER(TRIM(p.shopping_name)) = ? AND i.product_id != ? AND p.unit = ? AND i.quantity > 0 + "); + $famStmt->execute([$sNameKey, $productId, $prodUnit]); + $totalFamilyRemaining = round($totalRemaining + (float)($famStmt->fetchColumn() ?: 0), 6); + } + } + $response = ['success' => true, 'remaining' => $remaining, 'added_to_bring' => $addedToBring, - 'total_remaining' => $totalRemaining]; + 'total_remaining' => $totalRemaining, 'total_family_remaining' => $totalFamilyRemaining]; if ($prodInfo) { $response['product_name'] = $prodInfo['name']; $response['product_brand'] = $prodInfo['brand'] ?: ''; @@ -3073,10 +3172,18 @@ function updateInventory(PDO $db): void { } } - // Update unit on the product if provided + // Update unit on the product if provided. + // When setting unit back to 'pz', also ensure default_quantity >= 1 so the + // barcode-scan auto-detect (which only fires on default_quantity === 0) won't + // silently revert the user's correction on the next scan. if (isset($input['unit']) && isset($input['product_id'])) { - $stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$input['unit'], $input['product_id']]); + $newUnit = $input['unit']; + if ($newUnit === 'pz') { + $stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = CASE WHEN default_quantity < 1 THEN 1 ELSE default_quantity END, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + } else { + $stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + } + $stmt->execute([$newUnit, $input['product_id']]); } // Update package info if provided @@ -5471,6 +5578,7 @@ REGOLE: 7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged. 8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullateur"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed. 9. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}. +10. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) ≠ 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' ≠ 'Latte UHT' ≠ 'Panna'; 'Farina 00' ≠ 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile. DISPENSA: $ingredientsText @@ -6418,6 +6526,7 @@ REGOLE: 8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed. 9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated. 10. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}. +11. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) ≠ 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' ≠ 'Latte UHT' ≠ 'Panna'; 'Farina 00' ≠ 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile. DISPENSA: $ingredientsText diff --git a/assets/js/app.js b/assets/js/app.js index 83c2947..34d03d3 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1862,6 +1862,14 @@ function estimateOpenedExpiryDays(product, location) { if (/\b(patata|patate|tubero)\b/.test(name)) return 4; if (/\baglio\b/.test(name)) return 14; + // ── F.extra: Bread in fridge (opened) ──────────────────────────────── + // Thin flatbreads (piadina, crescia, tigella) get mold very quickly + if (/\b(piadina|piadelle?|crescia|tigella)\b/.test(name)) return 2; + // Packaged sliced bread — preservatives help a bit + if (/\b(bauletto|pancarr[eè]|pan\s+carr[eè]?|tramezzin)\b/.test(name)) return 4; + // Generic bread in fridge + if (/\bpane\b/.test(cat)) return 3; + // ── G: Fridge condiments ───────────────────────────────────────────── if (/maionese|mayo|mayon/.test(name)) return 90; if (/\bketchup\b/.test(name)) return 90; @@ -6197,11 +6205,84 @@ async function quickUse(productId, location) { } async function deleteInventoryItem(id) { - if (confirm(t('confirm.remove_item'))) { - await api('inventory_delete', {}, 'POST', { id }); - closeModal(); - showToast(t('toast.product_removed'), 'success'); + const item = currentInventory.find(i => i.id === id); + const unit = item ? (item.unit || 'pz') : 'pz'; + const qty = item ? (parseFloat(item.quantity) || 0) : 0; + const canDiscardOne = item && (unit === 'pz' || unit === 'conf') && qty > 1; + + if (!canDiscardOne) { + // Simple case: confirm → delete the whole row + if (confirm(t('confirm.remove_item'))) { + await api('inventory_delete', {}, 'POST', { id }); + closeModal(); + showToast(t('toast.product_removed'), 'success'); + refreshCurrentPage(); + } + return; + } + + // Show a choice modal: 1 piece vs everything + const qtyDisplay = formatQuantity(qty, unit, item.default_quantity, item.package_unit); + document.getElementById('modal-content').innerHTML = ` + +

${escapeHtml(item.name)} · ${qtyDisplay}

+
+ + + +
+ `; + document.getElementById('modal-overlay').style.display = 'flex'; +} + +async function _discardOnePiece(inventoryId) { + const item = currentInventory.find(i => i.id === inventoryId); + if (!item) { closeModal(); return; } + closeModal(); + showLoading(true); + try { + await api('inventory_use', {}, 'POST', { + product_id: item.product_id, + quantity: 1, + location: item.location, + notes: 'Buttato' + }); + showLoading(false); + showToast(t('toast.thrown_away_partial', { qty: 1, unit: item.unit || 'pz', name: item.name }), 'success'); refreshCurrentPage(); + } catch(e) { + showLoading(false); + showToast(t('error.connection'), 'error'); + } +} + +async function _discardAllFromModal(inventoryId) { + const item = currentInventory.find(i => i.id === inventoryId); + if (!item) { closeModal(); return; } + closeModal(); + showLoading(true); + try { + await api('inventory_use', {}, 'POST', { + product_id: item.product_id, + use_all: true, + location: item.location, + notes: 'Buttato' + }); + showLoading(false); + showToast(t('toast.thrown_away', { name: item.name }), 'success'); + refreshCurrentPage(); + } catch(e) { + showLoading(false); + showToast(t('error.connection'), 'error'); } } @@ -6319,7 +6400,15 @@ async function submitEditInventory(e, id, productId) { const loc = document.getElementById('edit-loc').value; const expiry = document.getElementById('edit-expiry').value || null; const unit = document.getElementById('edit-unit').value; - + + // Safety guard: warn if quantity is unreasonably large to prevent unit-confusion errors + // (e.g. user types "183" thinking it's ml, but the field expects conf units) + const _largeQtyLimits = { conf: 50, pz: 200, g: 10000, ml: 10000 }; + const _largeQtyLimit = _largeQtyLimits[unit] ?? 500; + if (qty > _largeQtyLimit) { + if (!confirm(t('edit.confirm_large_qty').replace('{qty}', qty).replace('{unit}', unit))) return; + } + const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId, vacuum_sealed: document.getElementById('edit-vacuum')?.checked ? 1 : 0 }; @@ -6792,8 +6881,11 @@ async function onBarcodeDetected(barcode) { const localResult = await api('search_barcode', { barcode }); if (localResult.found) { currentProduct = localResult.product; - // If product was saved with 'pz' but has weight info in notes, fix defaults - if (currentProduct.unit === 'pz' && currentProduct.default_quantity <= 1 && currentProduct.notes) { + // If product was saved with 'pz' but has weight info in notes, fix defaults. + // Only run if default_quantity === 0 (strictly unset): a value of 1 or higher + // means the user (or a previous auto-detect pass) already confirmed the unit, + // and re-running here would undo manual corrections. + if (currentProduct.unit === 'pz' && currentProduct.default_quantity === 0 && currentProduct.notes) { const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/); if (pesoMatch) { const weightStr = pesoMatch[1].trim(); @@ -9429,6 +9521,13 @@ function showLowStockBringPrompt(result, afterCallback) { const unit = result.product_unit || currentProduct?.unit || 'pz'; const defaultQty = result.product_default_qty || parseFloat(currentProduct?.default_quantity) || 0; const totalRemaining = result.total_remaining; + // If the backend provided a family-wide total (all products sharing the same + // shopping_name and unit, e.g. "Uova Sfoglia Gialla" + "Uova biologiche"), + // use that for the low-stock check so that a second scanned package of eggs + // prevents a false "running out" warning. + const familyTotal = (result.total_family_remaining !== undefined) + ? result.total_family_remaining + : totalRemaining; // ── Fully depleted: no need to ask — backend already added to Bring! ── // Skip the modal entirely and proceed to the next step (e.g. move modal). @@ -9454,7 +9553,7 @@ function showLowStockBringPrompt(result, afterCallback) { return; } - if (!isLowStock(totalRemaining, unit, defaultQty)) { + if (!isLowStock(familyTotal, unit, defaultQty)) { if (afterCallback) afterCallback(); return; } @@ -14136,6 +14235,11 @@ sensor: - total_items - shopping_items - expiring_list + - expired_list + - low_stock_list + - next_expiry_name + - next_expiry_date + - days_to_next_expiry - last_updated unit_of_measurement: "items" device_class: null @@ -14154,6 +14258,18 @@ sensor: resource: "${base}/api/?action=ha_sensor&sensor=shopping" scan_interval: 180 value_template: "{{ value_json.state }}" + unit_of_measurement: "items" + + # Full product inventory — all items with complete details + - platform: rest + name: "EverShelf Products" + unique_id: evershelf_products + resource: "${base}/api/?action=ha_sensor&sensor=product" + scan_interval: 600 + value_template: "{{ value_json.state }}" + json_attributes: + - items + - last_updated unit_of_measurement: "items"`; } @@ -16157,19 +16273,57 @@ function _ssDonut(label, val, color) { // Load all data needed for screensaver facts async function loadScreensaverData() { try { - const [statsRes, invRes, bringRes] = await Promise.all([ + const [statsRes, invRes, bringRes, smartRes] = await Promise.all([ api('stats'), api('inventory_list'), - api('shopping_list').catch(() => null) + api('shopping_list').catch(() => null), + api('smart_shopping').catch(() => null) ]); _screensaverData = { stats: statsRes, inventory: invRes.inventory || [], shopping: bringRes && bringRes.success ? (bringRes.purchase || []) : [] }; + // Keep smartShoppingItems fresh so _screensaverAutoAddItems has current data + if (smartRes && Array.isArray(smartRes.items)) { + smartShoppingItems = smartRes.items; + } } catch (e) { _screensaverData = { stats: {}, inventory: [], shopping: [] }; } + // Silently add critical/high-urgency items to Bring! while screensaver is showing + _screensaverAutoAddItems(); +} + +/** + * Silently adds critical and high-urgency shopping items to Bring! when the + * screensaver activates. No toast shown — the shopping panel count updates + * automatically after the add. Rate-limited to once per 30 minutes per session. + */ +async function _screensaverAutoAddItems() { + const RATE_MS = 30 * 60 * 1000; + const lastRun = parseInt(sessionStorage.getItem('_ssAutoAddTs') || '0'); + if (Date.now() - lastRun < RATE_MS) return; + + const toAdd = smartShoppingItems.filter(i => { + if (i.on_bring) return false; + if (_isBringPurchased(i.name, i.urgency)) return false; + return i.urgency === 'critical' || i.urgency === 'high'; + }); + if (toAdd.length === 0) return; + + sessionStorage.setItem('_ssAutoAddTs', String(Date.now())); + const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) })); + try { + const result = await api('shopping_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); + if (result.success && result.added > 0) { + _markAutoAddedBring(itemsToAdd.map(i => i.name)); + logOperation('bring_auto_add_screensaver', { added: itemsToAdd.map(i => i.name) }); + // Refresh bring list silently then update screensaver counter + loadShoppingList._bgCall = true; + loadShoppingList().then(() => updateScreensaverShopping()); + } + } catch (e) { /* ignore */ } } // Show next random fact with fade in/out @@ -16293,12 +16447,8 @@ function generateScreensaverFact() { facts.push(() => t('facts.expiring_this_month').replace('{n}', expiringThisMonth.length)); } - // --- Shopping list facts (skip count/names — already shown in the shopping panel) --- - if (shop.length > 0) { - const names = shop.slice(0, 3).map(i => i.name).join(', '); - const extra = shop.length > 3 ? ` ${t('facts.shopping_more').replace('{n}', shop.length - 3)}` : ''; - facts.push(() => t('facts.shopping_add').replace('{names}', names + extra)); - } + // Shopping list count/items are already visible in the shopping panel on the screensaver. + // Items are added automatically by _screensaverAutoAddItems — no manual-action text needed. if (shop.length === 0) { facts.push(() => t('facts.shopping_empty')); } diff --git a/docs/wiki/Home-Assistant.md b/docs/wiki/Home-Assistant.md index fa0fd7a..5e443f3 100644 --- a/docs/wiki/Home-Assistant.md +++ b/docs/wiki/Home-Assistant.md @@ -35,10 +35,14 @@ Add EverShelf pantry data as native HA sensor entities that update automatically | URL | Returns | Sensor | |-----|---------|--------| -| `/api/?action=ha_sensor` | Items expiring soon (≤3 days) | `sensor.evershelf_overview` | +| `/api/?action=ha_sensor` | Items expiring soon (≤`HA_EXPIRY_DAYS` days) | `sensor.evershelf_overview` | | `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` | | `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` | | `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` | +| `/api/?action=ha_sensor&sensor=product` | Full inventory — all items with complete details | `sensor.evershelf_products` | +| `/api/?action=ha_sensor&sensor=product&id=42` | Full details for inventory row `id=42` | — | +| `/api/?action=ha_sensor&sensor=product&name=milk` | Full details for items whose name contains "milk" | — | +| `/api/?action=ha_sensor&sensor=product&location=frigo` | All items in a specific location | — | ### Generate & Copy YAML @@ -61,7 +65,12 @@ sensor: - expired_items - total_items - shopping_items - - expiring_list + - expiring_list # full product details for expiring items + - expired_list # full product details for expired items + - low_stock_list # full product details for items with quantity ≤ 1 + - next_expiry_name + - next_expiry_date + - days_to_next_expiry - last_updated unit_of_measurement: "items" @@ -72,10 +81,62 @@ sensor: scan_interval: 180 value_template: "{{ value_json.state }}" unit_of_measurement: "items" + + # Full product inventory — each item includes all details (location, brand, category, …) + - platform: rest + name: "EverShelf Products" + unique_id: evershelf_products + resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=product" + scan_interval: 600 + value_template: "{{ value_json.state }}" + json_attributes: + - items + - last_updated + unit_of_measurement: "items" ``` Restart Home Assistant after editing `configuration.yaml`. +Every product entry inside `expiring_list`, `expired_list`, `low_stock_list`, and `sensor=product` responses follows the same schema: + +```json +{ + "product_id": 42, + "inventory_id": 7, + "name": "Latte intero", + "brand": "Parmalat", + "category": "Lattiero-caseari", + "quantity": 2.0, + "unit": "conf", + "default_quantity": 1000.0, + "package_unit": "ml", + "location": "frigo", + "expiry_date": "2025-06-15", + "days_remaining": 3, + "opened_at": "2025-06-10", + "vacuum_sealed": false +} +``` + +Field details: + +| Field | Type | Description | +|-------|------|-------------| +| `product_id` | int | Products table ID | +| `inventory_id` | int | Inventory row ID | +| `name` | string | Product name | +| `brand` | string\|null | Brand (if set) | +| `category` | string\|null | Category (if set) | +| `quantity` | float | Current quantity in inventory | +| `unit` | string | Unit (`conf`, `g`, `ml`, `pz`, …) | +| `default_quantity` | float | Default package size (e.g. 1000 for 1-litre carton) | +| `package_unit` | string\|null | Unit of the default package (`g`, `ml`) | +| `location` | string\|null | Storage location (`frigo`, `freezer`, `dispensa`, …) | +| `expiry_date` | string\|null | ISO date `YYYY-MM-DD` | +| `days_remaining` | int\|null | Days until expiry (negative = already expired) | +| `opened_at` | string\|null | ISO date when the package was opened | +| `vacuum_sealed` | bool | Whether the item is vacuum-sealed | + --- ## Webhook Automations @@ -109,9 +170,24 @@ EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur. "type": "expiring_soon", "count": 3, "days": 3, - "summary": "3 products expiring within 3 days", + "summary": "Milk, Yogurt, Butter", "items": [ - { "name": "Milk", "expiry_date": "2025-06-14", "quantity": 1, "unit": "l" } + { + "product_id": 42, + "inventory_id": 7, + "name": "Milk", + "brand": "Parmalat", + "category": "Dairy", + "quantity": 2.0, + "unit": "conf", + "default_quantity": 1000.0, + "package_unit": "ml", + "location": "frigo", + "expiry_date": "2025-06-14", + "days_remaining": 2, + "opened_at": "2025-06-10", + "vacuum_sealed": false + } ] } } @@ -128,12 +204,25 @@ action: - service: notify.telegram_bot data: message: > - 🥫 EverShelf: {{ trigger.json.data.summary }} + 🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon {% for item in trigger.json.data.items %} - — {{ item.name }} (expires {{ item.expiry_date }}) + — {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} · + {{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} · + expires {{ item.expiry_date }} ({{ item.days_remaining }} days) {% endfor %} ``` +### Example: Automation on location + +You can filter by location in the automation template to only alert for fridge items: + +```yaml +condition: + - condition: template + value_template: > + {{ trigger.json.data.items | selectattr('location','eq','frigo') | list | length > 0 }} +``` + --- ## Push Notifications diff --git a/translations/de.json b/translations/de.json index d463bff..8570134 100644 --- a/translations/de.json +++ b/translations/de.json @@ -1086,11 +1086,13 @@ "offline_ai_disabled": "Offline nicht verfügbar", "offline_cache_ready": "Offline — {n} Produkte im Cache" }, + "confirm_placeholder_search": null, "confirm": { "remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?", "kiosk_exit": "Kioskmodus verlassen?", "cancel": "Abbrechen", - "proceed": "Bestätigen" + "proceed": "Bestätigen", + "discard_one": "1 Stück wegwerfen" }, "location": { "dispensa": "Vorratskammer", @@ -1102,7 +1104,8 @@ "unknown_hint": "Produktname und Informationen eingeben", "label_name": "🏷️ Produktname", "choose_location_title": "Welchen Ort?", - "choose_location_hint": "Wähle den zu bearbeitenden Ort:" + "choose_location_hint": "Wähle den zu bearbeitenden Ort:", + "confirm_large_qty": "Du setzt die Menge auf {qty} {unit}. Das scheint ungewöhnlich hoch zu sein. Bestätigen?" }, "screensaver": { "recipe_btn": "Rezepte", diff --git a/translations/en.json b/translations/en.json index 53e717c..bd8ebb7 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1086,11 +1086,13 @@ "offline_ai_disabled": "Not available offline", "offline_cache_ready": "Offline — {n} items cached" }, + "confirm_placeholder_search": null, "confirm": { "remove_item": "Do you really want to remove this product from inventory?", "kiosk_exit": "Exit kiosk mode?", "cancel": "Cancel", - "proceed": "Confirm" + "proceed": "Confirm", + "discard_one": "Discard 1 piece" }, "location": { "dispensa": "Pantry", @@ -1102,7 +1104,8 @@ "unknown_hint": "Enter the product name and information", "label_name": "🏷️ Product name", "choose_location_title": "Which location?", - "choose_location_hint": "Choose the location to edit:" + "choose_location_hint": "Choose the location to edit:", + "confirm_large_qty": "You are setting the quantity to {qty} {unit}. This seems unusually high. Confirm?" }, "screensaver": { "recipe_btn": "Recipes", diff --git a/translations/es.json b/translations/es.json index bdeeec5..dd7cc40 100644 --- a/translations/es.json +++ b/translations/es.json @@ -1037,11 +1037,13 @@ "offline_ai_disabled": "No disponible sin conexión", "offline_cache_ready": "Offline — {n} productos en caché" }, + "confirm_placeholder_search": null, "confirm": { "remove_item": "¿Realmente quieres eliminar este producto del inventario?", "kiosk_exit": "¿Salir del modo kiosco?", "cancel": "Cancelar", - "proceed": "Confirmar" + "proceed": "Confirmar", + "discard_one": "Tirar 1 unidad" }, "location": { "dispensa": "Despensa", @@ -1053,7 +1055,8 @@ "unknown_hint": "Introduce el nombre del producto y la información", "label_name": "🏷️ Nombre del producto", "choose_location_title": "¿Qué ubicación?", - "choose_location_hint": "Elige la ubicación a editar:" + "choose_location_hint": "Elige la ubicación a editar:", + "confirm_large_qty": "Estás configurando la cantidad a {qty} {unit}. Esto parece inusualmente alto. ¿Confirmar?" }, "screensaver": { "recipe_btn": "Recetas", diff --git a/translations/fr.json b/translations/fr.json index c7bbaba..eda3281 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -1037,11 +1037,13 @@ "offline_ai_disabled": "Indisponible hors ligne", "offline_cache_ready": "Offline — {n} produits en cache" }, + "confirm_placeholder_search": null, "confirm": { "remove_item": "Voulez-vous vraiment supprimer ce produit de l'inventaire ?", "kiosk_exit": "Quitter le mode kiosque ?", "cancel": "Annuler", - "proceed": "Confirmer" + "proceed": "Confirmer", + "discard_one": "Jeter 1 pièce" }, "location": { "dispensa": "Garde-manger", @@ -1053,7 +1055,8 @@ "unknown_hint": "Entrez le nom du produit et les informations", "label_name": "🏷️ Nom du produit", "choose_location_title": "Quel emplacement ?", - "choose_location_hint": "Choisissez l'emplacement à modifier :" + "choose_location_hint": "Choisissez l'emplacement à modifier :", + "confirm_large_qty": "Vous définissez la quantité à {qty} {unit}. Cela semble inhabituellement élevé. Confirmer ?" }, "screensaver": { "recipe_btn": "Recettes", diff --git a/translations/it.json b/translations/it.json index 40b45cf..d725aff 100644 --- a/translations/it.json +++ b/translations/it.json @@ -1090,7 +1090,8 @@ "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?", "kiosk_exit": "Uscire dalla modalità kiosk?", "cancel": "Annulla", - "proceed": "Conferma" + "proceed": "Conferma", + "discard_one": "Butta 1 pezzo" }, "location": { "dispensa": "Dispensa", @@ -1102,7 +1103,8 @@ "unknown_hint": "Inserisci il nome e le informazioni del prodotto", "label_name": "🏷️ Nome prodotto", "choose_location_title": "Quale modifica?", - "choose_location_hint": "Scegli la posizione da modificare:" + "choose_location_hint": "Scegli la posizione da modificare:", + "confirm_large_qty": "Stai impostando la quantità a {qty} {unit}. Questo sembra un valore insolitamente alto. Confermare?" }, "screensaver": { "recipe_btn": "Ricette",