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}
+