diff --git a/CHANGELOG.md b/CHANGELOG.md index bb341d5..b0f600f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ 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.36] - 2026-06-04 + +### Added +- **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations. +- **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages). +- **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input. +- **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history. +- **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product. +- **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge). + +### Fixed +- **Unified shopping total** — Dashboard, Spesa page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too. +- **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation. +- **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups. +- **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs. +- **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust. +- **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation. +- **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches. +- **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name. +- **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock. + ## [1.7.35] - 2026-06-02 ### Fixed diff --git a/README.md b/README.md index 8013406..fed9686 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ [](https://www.sqlite.org/) [](Dockerfile) [](translations/) -[](CHANGELOG.md) +[](CHANGELOG.md) [](https://github.com/dadaloop82/EverShelf/stargazers) [](https://github.com/dadaloop82/EverShelf/commits/main) [](https://github.com/dadaloop82/EverShelf/graphs/contributors) @@ -86,6 +86,7 @@ Connect your pantry to your smart home in minutes — no YAML, no manual sensor - **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones - **Storage & shelf-life hint** — When adding a new product, Gemini suggests the optimal storage location and shelf-life in the background; shown as an inline AI badge next to the expiry estimate - **Recipe generation** — Get personalized recipes based on what's in your pantry; streams live via Server-Sent Events so results appear as they are generated +- **Recipe stock hints** — Each pantry ingredient shows how much you have and what remains after use; when the leftover would be less than 5% of the full sealed package (10% for an already-opened partial pack), the recipe automatically uses everything on hand to avoid waste - **Smart chat assistant** — Ask questions about your inventory, get cooking tips - **Shopping suggestions with tips** — AI-powered purchase recommendations, each enriched with a short practical buying/storing tip - **Anomaly explanation** — "Explain" button on anomaly banners explains in plain language why a discrepancy likely occurred and what to do diff --git a/api/index.php b/api/index.php index dae2550..43f4089 100644 --- a/api/index.php +++ b/api/index.php @@ -650,6 +650,7 @@ if ($rateLimitAction) { // the explicit header is an additional defence-in-depth check for POST writes. $_writeActions = [ 'inventory_add','inventory_use','inventory_update','inventory_remove', + 'inventory_confirm_finished','inventory_restore_ghost', 'product_save','product_delete','product_merge', 'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names', 'shopping_add','shopping_remove', @@ -715,6 +716,9 @@ try { case 'product_delete': deleteProduct($db); break; + case 'product_merge': + mergeProduct($db); + break; case 'products_list': listProducts($db); break; @@ -747,6 +751,9 @@ try { case 'inventory_confirm_finished': confirmFinished($db); break; + case 'inventory_restore_ghost': + restoreGhostInventory($db); + break; case 'inventory_summary': inventorySummary($db); break; @@ -2512,8 +2519,18 @@ function saveProduct(PDO $db): void { ? $input['shopping_name'] : computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? ''); - if (!empty($input['id'])) { - // Update existing + $id = !empty($input['id']) ? (int)$input['id'] : 0; + $merged = false; + if (!$id) { + $dupId = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $input['barcode'] ?? null, null); + if ($dupId) { + $id = $dupId; + $merged = true; + } + } + + if ($id) { + // Update existing (or matched duplicate) $stmt = $db->prepare(" UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?, @@ -2526,9 +2543,9 @@ function saveProduct(PDO $db): void { $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', $input['barcode'] ?? null, $input['package_unit'] ?? '', - $shoppingName, $nutriJson, $input['id'] + $shoppingName, $nutriJson, $id ]); - echo json_encode(['success' => true, 'id' => $input['id']]); + echo json_encode(['success' => true, 'id' => $id, 'merged' => $merged]); } else { // Insert new $stmt = $db->prepare(" @@ -2863,8 +2880,19 @@ function useFromInventory(PDO $db): void { // Guard against accidental double-consume triggers (scale jitter, double tap, // delayed/offline replay burst). We only apply this stricter gate to manual // uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected. - if (!$useAll) { - $dedupWindow = ($notes === '') ? 120 : 12; + $dedupWindow = $useAll ? 60 : (($notes === '') ? 120 : 12); + if ($useAll) { + $dedup = $db->prepare( + "SELECT id, quantity, created_at FROM transactions + WHERE product_id = ? + AND type IN ('out','waste') + AND undone = 0 + AND created_at >= datetime('now', '-' || ? || ' seconds') + ORDER BY id DESC + LIMIT 1" + ); + $dedup->execute([$productId, $dedupWindow]); + } else { $dedup = $db->prepare( "SELECT id, quantity, created_at FROM transactions WHERE product_id = ? @@ -2877,25 +2905,26 @@ function useFromInventory(PDO $db): void { LIMIT 1" ); $dedup->execute([$productId, $location, $notes, $dedupWindow]); - $recent = $dedup->fetch(); - if ($recent) { - EverLog::warn('useFromInventory duplicate blocked', [ - 'product_id' => $productId, - 'location' => $location, - 'window_s' => $dedupWindow, - 'recent_tx_id' => $recent['id'] ?? null, - 'recent_qty' => $recent['quantity'] ?? null, - 'recent_created_at' => $recent['created_at'] ?? null, - 'requested_qty' => $quantity, - 'notes' => $notes, - ]); - echo json_encode([ - 'success' => false, - 'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.', - 'duplicate' => true, - ]); - return; - } + } + $recent = $dedup->fetch(); + if ($recent) { + EverLog::warn('useFromInventory duplicate blocked', [ + 'product_id' => $productId, + 'location' => $location, + 'use_all' => $useAll, + 'window_s' => $dedupWindow, + 'recent_tx_id' => $recent['id'] ?? null, + 'recent_qty' => $recent['quantity'] ?? null, + 'recent_created_at' => $recent['created_at'] ?? null, + 'requested_qty' => $quantity, + 'notes' => $notes, + ]); + echo json_encode([ + 'success' => false, + 'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.', + 'duplicate' => true, + ]); + return; } // ───────────────────────────────────────────────────────────────────── @@ -3325,17 +3354,168 @@ function updateInventory(PDO $db): void { function deleteInventory(PDO $db): void { EverLog::info('deleteInventory'); $input = json_decode(file_get_contents('php://input'), true); - $id = $input['id'] ?? 0; - $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $id = (int)($input['id'] ?? 0); + if (!$id) { + http_response_code(400); + echo json_encode(['error' => 'Inventory ID required']); + return; + } + + $stmt = $db->prepare("SELECT id, product_id, quantity, location FROM inventory WHERE id = ?"); $stmt->execute([$id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + http_response_code(404); + echo json_encode(['error' => 'Inventory row not found']); + return; + } + + $qty = (float)$row['quantity']; + if ($qty > 0.0001) { + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)") + ->execute([(int)$row['product_id'], $qty, $row['location'], '[Eliminazione inventario]']); + } + + $db->prepare("DELETE FROM inventory WHERE id = ?")->execute([$id]); echo json_encode(['success' => true]); } +function productQtyThreshold(string $unit): float { + static $thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5]; + return $thresholds[$unit] ?? 0.5; +} + +function normalizeProductName(string $name): string { + return mb_strtolower(trim($name)); +} + +function normalizeProductBrand(string $brand): string { + return mb_strtolower(trim($brand)); +} + +function brandsCompatible(string $a, string $b): bool { + $na = normalizeProductBrand($a); + $nb = normalizeProductBrand($b); + return $na === $nb || $na === '' || $nb === ''; +} + +function findDuplicateProductId(PDO $db, string $name, string $brand, ?string $barcode, ?int $excludeId = null): ?int { + if ($barcode !== null && trim($barcode) !== '') { + $sql = "SELECT id FROM products WHERE barcode = ? AND barcode IS NOT NULL AND TRIM(barcode) != ''"; + $params = [$barcode]; + if ($excludeId) { + $sql .= " AND id != ?"; + $params[] = $excludeId; + } + $sql .= " ORDER BY id ASC LIMIT 1"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $id = $stmt->fetchColumn(); + if ($id) { + return (int)$id; + } + } + + $nName = normalizeProductName($name); + if ($nName === '') { + return null; + } + + $sql = "SELECT id, brand FROM products WHERE lower(trim(name)) = ?"; + $params = [$nName]; + if ($excludeId) { + $sql .= " AND id != ?"; + $params[] = $excludeId; + } + $stmt = $db->prepare($sql); + $stmt->execute($params); + $candidates = $stmt->fetchAll(PDO::FETCH_ASSOC); + if (!$candidates) { + return null; + } + + $targetBrand = normalizeProductBrand($brand); + $compatible = null; + foreach ($candidates as $c) { + $cBrand = normalizeProductBrand($c['brand'] ?? ''); + if ($cBrand === $targetBrand) { + return (int)$c['id']; + } + if ($compatible === null && brandsCompatible($brand, $c['brand'] ?? '')) { + $compatible = (int)$c['id']; + } + } + return $compatible; +} + +function getProductLedgerBalance(PDO $db, int $productId): array { + $stmt = $db->prepare(" + SELECT + COALESCE(SUM(CASE WHEN type = 'in' AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_in, + COALESCE(SUM(CASE WHEN type IN ('out','waste') AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_out + FROM transactions + WHERE product_id = ? + "); + $stmt->execute([$productId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0]; + $stockStmt = $db->prepare("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ?"); + $stockStmt->execute([$productId]); + return [ + 'total_in' => (float)$row['total_in'], + 'total_out' => (float)$row['total_out'], + 'stock' => (float)$stockStmt->fetchColumn(), + ]; +} + +function mergeProducts(PDO $db, int $keepId, int $dropId): void { + if ($keepId === $dropId) { + return; + } + $check = $db->prepare("SELECT id FROM products WHERE id IN (?, ?)"); + $check->execute([$keepId, $dropId]); + if ($check->rowCount() < 2) { + throw new RuntimeException('One or both products not found'); + } + + $db->beginTransaction(); + try { + $db->prepare("UPDATE inventory SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]); + $db->prepare("UPDATE transactions SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]); + $db->prepare("DELETE FROM products WHERE id = ?")->execute([$dropId]); + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; + } +} + +function mergeProduct(PDO $db): void { + EverLog::info('mergeProduct'); + $input = json_decode(file_get_contents('php://input'), true); + $keepId = (int)($input['keep_id'] ?? $input['canonical_id'] ?? 0); + $dropId = (int)($input['drop_id'] ?? $input['duplicate_id'] ?? 0); + if (!$keepId || !$dropId) { + http_response_code(400); + echo json_encode(['error' => 'keep_id and drop_id required']); + return; + } + + try { + mergeProducts($db, $keepId, $dropId); + echo json_encode(['success' => true, 'keep_id' => $keepId, 'drop_id' => $dropId]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +} + /** - * Returns products whose entire inventory is at quantity = 0 AND whose + * Returns products whose ledger balance exceeds stock (including vanished rows). * transaction balance (total_in - total_out) is still significantly positive — * meaning the system suspects the product ran out prematurely (scale drift, - * missed registration, etc.). + * missed registration, deleted inventory row, etc.). * * Products where the balance is at/near zero are legitimately finished by the * user; those rows are silently deleted here (no banner needed). @@ -3344,30 +3524,27 @@ function getFinishedItems(PDO $db): void { EverLog::debug('getFinishedItems'); $rows = $db->query(" SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, p.image_url, p.barcode, - MIN(i.location) AS location, - MAX(i.updated_at) AS updated_at, COALESCE(SUM(CASE WHEN t.type = 'in' AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_in, - COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out + COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out, + COALESCE((SELECT SUM(i2.quantity) FROM inventory i2 WHERE i2.product_id = p.id), 0) AS stock_qty, + (SELECT COUNT(*) FROM inventory i3 WHERE i3.product_id = p.id) AS inv_rows, + (SELECT i4.location FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_location, + (SELECT i4.updated_at FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_updated, + (SELECT t2.location FROM transactions t2 WHERE t2.product_id = p.id AND t2.undone = 0 ORDER BY t2.created_at DESC LIMIT 1) AS tx_location FROM products p - JOIN inventory i ON i.product_id = p.id LEFT JOIN transactions t ON t.product_id = p.id - WHERE NOT EXISTS ( - SELECT 1 FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0 - ) GROUP BY p.id - ORDER BY MAX(i.updated_at) DESC + HAVING stock_qty <= 0.001 AND total_in > 0 + ORDER BY (total_in - total_out) DESC ")->fetchAll(PDO::FETCH_ASSOC); - // Per-unit threshold: residue below this is considered normal rounding/finish - $thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5]; - $suspicious = []; foreach ($rows as $r) { $expected = (float)$r['total_in'] - (float)$r['total_out']; - $threshold = $thresholds[$r['unit']] ?? 0.5; + $threshold = productQtyThreshold($r['unit']); if ($expected > $threshold) { - // Transaction balance says stock should remain — show banner + $location = $r['inv_location'] ?: $r['tx_location'] ?: 'dispensa'; $suspicious[] = [ 'product_id' => (int)$r['product_id'], 'name' => $r['name'], @@ -3377,13 +3554,14 @@ function getFinishedItems(PDO $db): void { 'package_unit' => $r['package_unit'], 'image_url' => $r['image_url'], 'barcode' => $r['barcode'], - 'location' => $r['location'], - 'updated_at' => $r['updated_at'], + 'location' => $location, + 'updated_at' => $r['inv_updated'], 'expected_qty' => round($expected, 3), + 'ghost' => true, + 'vanished' => ((int)$r['inv_rows']) === 0, ]; } else { - // Legitimately finished — delete silently, no banner - $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0") + $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0") ->execute([$r['product_id']]); } } @@ -3392,7 +3570,8 @@ function getFinishedItems(PDO $db): void { } /** - * Permanently delete all qty=0 inventory rows for a product after user confirms it is finished. + * Permanently reconcile a finished/ghost product: log the missing quantity as + * an explicit out transaction, then delete any zero-qty inventory rows. */ function confirmFinished(PDO $db): void { $input = json_decode(file_get_contents('php://input'), true); @@ -3403,10 +3582,89 @@ function confirmFinished(PDO $db): void { echo json_encode(['error' => 'product_id required']); return; } - $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")->execute([$productId]); + + $prod = $db->prepare("SELECT unit FROM products WHERE id = ?"); + $prod->execute([$productId]); + $unit = $prod->fetchColumn(); + if (!$unit) { + http_response_code(404); + echo json_encode(['error' => 'Product not found']); + return; + } + + $bal = getProductLedgerBalance($db, $productId); + $expected = $bal['total_in'] - $bal['total_out']; + $threshold = productQtyThreshold((string)$unit); + + if ($expected > $threshold) { + $locStmt = $db->prepare("SELECT location FROM inventory WHERE product_id = ? ORDER BY updated_at DESC LIMIT 1"); + $locStmt->execute([$productId]); + $location = $locStmt->fetchColumn(); + if (!$location) { + $locStmt = $db->prepare("SELECT location FROM transactions WHERE product_id = ? AND undone = 0 ORDER BY created_at DESC LIMIT 1"); + $locStmt->execute([$productId]); + $location = $locStmt->fetchColumn(); + } + $location = $location ?: 'dispensa'; + $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)") + ->execute([$productId, round($expected, 3), $location, '[Riconciliazione] Confermato esaurito']); + } + + $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")->execute([$productId]); echo json_encode(['success' => true]); } +/** + * Restore stock for a ghost product without adding a new purchase (in) transaction. + */ +function restoreGhostInventory(PDO $db): void { + EverLog::info('restoreGhostInventory'); + $input = json_decode(file_get_contents('php://input'), true); + $productId = (int)($input['product_id'] ?? 0); + $quantity = (float)($input['quantity'] ?? 0); + $location = trim((string)($input['location'] ?? 'dispensa')) ?: 'dispensa'; + + if (!$productId || $quantity <= 0) { + http_response_code(400); + echo json_encode(['error' => 'product_id and quantity required']); + return; + } + + $prod = $db->prepare("SELECT id FROM products WHERE id = ?"); + $prod->execute([$productId]); + if (!$prod->fetchColumn()) { + http_response_code(404); + echo json_encode(['error' => 'Product not found']); + return; + } + + $stmt = $db->prepare(" + SELECT id, quantity FROM inventory + WHERE product_id = ? AND location = ? AND opened_at IS NULL + ORDER BY CASE WHEN quantity > 0 THEN 0 ELSE 1 END, updated_at DESC + LIMIT 1 + "); + $stmt->execute([$productId, $location]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($row) { + $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + ->execute([$quantity, (int)$row['id']]); + $invId = (int)$row['id']; + } else { + $db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)") + ->execute([$productId, $location, $quantity]); + $invId = (int)$db->lastInsertId(); + } + + echo json_encode([ + 'success' => true, + 'inventory_id' => $invId, + 'quantity' => $quantity, + 'location' => $location, + ]); +} + function inventorySummary(PDO $db): void { EverLog::debug('inventorySummary'); $stmt = $db->query(" @@ -5452,6 +5710,142 @@ PROMPT; return $text; } +/** Parse "200 g" / "2 pz" style recipe qty strings. */ +function recipeParseQtyString(string $qty): array { + $val = 0.0; + $unit = ''; + if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $qty, $qm)) { + $val = (float)str_replace(',', '.', $qm[1]); + $ru = strtolower($qm[2]); + if (strpos($ru, 'g') === 0) $unit = 'g'; + elseif ($ru === 'kg') { $unit = 'g'; $val *= 1000; } + elseif ($ru === 'ml') $unit = 'ml'; + elseif ($ru === 'cl') { $unit = 'ml'; $val *= 10; } + elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $unit = 'ml'; $val *= 1000; } + elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $unit = 'pz'; + elseif (strpos($ru, 'conf') === 0) $unit = 'conf'; + } + return ['val' => $val, 'unit' => $unit]; +} + +function recipeGetProductTotalStock(PDO $db, int $productId): float { + $stmt = $db->prepare('SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ? AND quantity > 0'); + $stmt->execute([$productId]); + return (float)$stmt->fetchColumn(); +} + +/** Full sealed unit size for % remainder (conf → default_quantity in g/ml per conf). */ +function recipeGetClosedProductBaseQty(array $ing): float { + $unit = $ing['inventory_unit'] ?? 'pz'; + $pkgSize = (float)($ing['default_quantity'] ?? 0); + $pkgUnit = strtolower($ing['package_unit'] ?? ''); + + if ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true)) { + return $pkgSize; + } + if ($unit === 'conf' && $pkgSize > 0) { + return $pkgSize; + } + if ($pkgSize > 0 && in_array($unit, ['g', 'ml', 'pz'], true)) { + return $pkgSize; + } + if ($unit === 'conf') { + return 1.0; + } + return 0.0; +} + +/** Use-all when leftover is < 5% of the sealed package (not current stock). */ +function recipeShouldUseAllRemainder(float $remainDisp, array $ing, float $stockDisp = 0): bool { + if ($remainDisp <= 0) { + return false; + } + $packageBase = recipeGetClosedProductBaseQty($ing); + if ($packageBase <= 0) { + return false; + } + $pct = $remainDisp / $packageBase; + if ($pct < 0.05) { + return true; + } + // Opened/partial: less than one full sealed unit on hand — allow up to 10% tail waste + if ($stockDisp > 0 && $stockDisp < $packageBase && $pct < 0.10) { + return true; + } + return false; +} + +/** Normalize use qty, apply <5% remainder → use-all, set stock_have/stock_remain hints. */ +function recipeFinalizeIngQty(array &$ing, float $totalStockQty): void { + $parsed = recipeParseQtyString($ing['qty'] ?? ''); + $recipeVal = $parsed['val']; + $recipeUnit = $parsed['unit']; + $unit = $ing['inventory_unit'] ?? 'pz'; + $pkgSize = (float)($ing['default_quantity'] ?? 0); + $pkgUnit = strtolower($ing['package_unit'] ?? ''); + $isConfSub = ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true)); + + $useQty = (float)($ing['qty_number'] ?? 0); + + // conf+weight: always prefer the recipe amount from the qty string (not inventory conf count) + if ($isConfSub && $recipeVal > 0 && $recipeUnit === $pkgUnit) { + $useQty = $recipeVal; + $ing['qty_number'] = round($useQty, 3); + $ing['qty'] = round($useQty) . ' ' . $pkgUnit; + } + + if ($isConfSub) { + $stockDisp = $totalStockQty * $pkgSize; + $useDisp = $useQty; + $dispUnit = $pkgUnit; + } else { + $stockDisp = $totalStockQty; + $useDisp = $useQty; + $dispUnit = $unit; + } + + if ($stockDisp <= 0 || $useDisp <= 0) { + $ing['stock_have'] = round($stockDisp, 2); + $ing['stock_remain'] = max(0, round($stockDisp - $useDisp, 2)); + $ing['stock_unit'] = $dispUnit; + return; + } + + $remainDisp = $stockDisp - $useDisp; + if (recipeShouldUseAllRemainder($remainDisp, $ing, $stockDisp)) { + $ing['use_all_suggested'] = true; + $useDisp = $stockDisp; + $remainDisp = 0; + if ($isConfSub) { + $ing['qty_number'] = round($useDisp, 1); + $ing['qty'] = round($useDisp) . ' ' . $pkgUnit; + } else { + $ing['qty_number'] = round($totalStockQty, 3); + if ($unit === 'pz') { + $ing['qty'] = round($totalStockQty, 2) . ' pz'; + } else { + $ing['qty'] = round($totalStockQty, ($unit === 'g' || $unit === 'ml') ? 0 : 2) . ' ' . $unit; + } + } + } + + $ing['stock_have'] = round($stockDisp, 2); + $ing['stock_remain'] = round($remainDisp, 2); + $ing['stock_unit'] = $dispUnit; +} + +function recipeApplyStockHintsToRecipe(PDO $db, array &$recipe): void { + if (empty($recipe['ingredients']) || !is_array($recipe['ingredients'])) return; + foreach ($recipe['ingredients'] as &$ing) { + if (empty($ing['from_pantry']) || empty($ing['product_id'])) continue; + $totalStock = recipeGetProductTotalStock($db, (int)$ing['product_id']); + if ($totalStock <= 0) continue; + $ing['inventory_qty_total'] = $totalStock; + recipeFinalizeIngQty($ing, $totalStock); + } + unset($ing); +} + // ===== RECIPE GENERATION WITH GEMINI ===== function generateRecipe(PDO $db): void { EverLog::debug('generateRecipe start'); @@ -6071,9 +6465,14 @@ PROMPT; if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); - if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { - $qtyNum = round($qtyNum * $defQty); - $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) { + if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) { + $qtyNum = $recipeVal; + $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; + } elseif ($qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); + $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } } } // Sanity check: qty_number should not exceed available @@ -6093,6 +6492,7 @@ PROMPT; } } unset($ing); + recipeApplyStockHintsToRecipe($db, $recipe); } EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]); @@ -6191,6 +6591,7 @@ PROMPT; if (!empty($recipe['ingredients'])) { _enrichChatIngredients($recipe['ingredients'], $items); } + recipeApplyStockHintsToRecipe($db, $recipe); echo json_encode(['success' => true, 'recipe' => $recipe]); } @@ -6305,6 +6706,7 @@ PROMPT; if (!empty($recipe['ingredients'])) { _enrichChatIngredients($recipe['ingredients'], $items); } + recipeApplyStockHintsToRecipe($db, $recipe); EverLog::info('recipe_from_ingredient ok', ['ingredient' => $ingredientName, 'title' => $recipe['title'] ?? '?', 'persons' => $persons]); echo json_encode(['success' => true, 'recipe' => $recipe]); @@ -6461,8 +6863,14 @@ function _enrichChatIngredients(array &$ingredients, array $items): void { if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); - if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { - $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) { + if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) { + $qtyNum = $recipeVal; + $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; + } elseif ($qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); + $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } } } if ($qtyNum > $invQty) $qtyNum = $invQty; @@ -7024,8 +7432,14 @@ PROMPT; if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); - if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { - $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) { + if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) { + $qtyNum = $recipeVal; + $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; + } elseif ($qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); + $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } } } if ($qtyNum > $invQty) $qtyNum = $invQty; @@ -7035,6 +7449,7 @@ PROMPT; } } unset($ing); + recipeApplyStockHintsToRecipe($db, $recipe); } $send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']); diff --git a/assets/css/style.css b/assets/css/style.css index a9498dd..04bc8a5 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -4671,6 +4671,13 @@ body.server-offline .bottom-nav { line-height: 1.3; } +.recipe-ing-stock { + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.35; + opacity: 0.9; +} + /* ===== SHOPPING SECTION (REPARTO) HEADERS ===== */ .shopping-section-divider { display: flex; diff --git a/assets/js/app.js b/assets/js/app.js index a5f45ab..efb2cf1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -515,10 +515,9 @@ function _scaleAutoFillUse(msg) { // Determine target unit let unit; if (_useConfMode) { - // Scale always reads weight (g/ml) — auto-switch to sub-unit mode if still in conf mode - if (_useConfMode._activeUnit !== 'sub') { - switchUseUnit('sub'); - } + // Scale reads weight (g/ml) — only auto-fill in sub-unit mode. + // If the user chose "Confezioni", respect that (fraction buttons / manual qty). + if (_useConfMode._activeUnit === 'conf') return; unit = (_useConfMode.packageUnit || '').toLowerCase(); } else { unit = _useNormalUnit; @@ -876,7 +875,7 @@ function _scaleUpdateStatus(state) { if (state === 'connected') { const devEl = document.getElementById('scale-diag-device'); const batEl = document.getElementById('scale-diag-battery'); - if (devEl) devEl.textContent = _scaleDevice || 'Dispositivo sconosciuto'; + if (devEl) devEl.textContent = _scaleDevice || t('settings.scale.unknown_device'); if (batEl) batEl.textContent = _scaleBattery != null ? `🔋 ${_scaleBattery}%` : ''; const weightEl = document.getElementById('scale-diag-weight'); if (weightEl && _scaleLatestWeight) { @@ -1075,7 +1074,7 @@ async function discoverScaleGateway() { btn.disabled = true; btn.textContent = '⏳'; status.style.display = 'block'; - status.textContent = '🔍 Scanning local network for scale gateway…'; + status.textContent = t('settings.scale.discover_scanning'); try { const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000), headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) } }); @@ -1087,7 +1086,10 @@ async function discoverScaleGateway() { const url = data.found[0]; const urlEl = document.getElementById('setting-scale-url'); if (urlEl) urlEl.value = url; - status.textContent = '✅ Gateway found: ' + url + (data.found.length > 1 ? ' (+' + (data.found.length - 1) + ' more)' : ''); + status.textContent = t('settings.scale.discover_found', { + url, + more: data.found.length > 1 ? ' (+' + (data.found.length - 1) + ' more)' : '', + }); status.style.color = 'var(--color-success, #059669)'; // Auto-save const s = getSettings(); @@ -1095,19 +1097,21 @@ async function discoverScaleGateway() { saveSettingsToStorage(s); scaleInit(); } else { - status.textContent = '❌ No gateway found on ' + (data.subnet || 'local network') + '. Make sure the Android app is running and on the same Wi-Fi.'; + status.textContent = t('settings.scale.discover_not_found', { subnet: data.subnet || 'local network' }); } } catch(e) { - status.textContent = '❌ Discovery failed: ' + (e.message || 'timeout'); + status.textContent = t('settings.scale.discover_failed', { error: e.message || 'timeout' }); } btn.disabled = false; - btn.textContent = '🔍 Auto'; + btn.textContent = t('settings.scale.discover_auto'); } // ===== i18n TRANSLATION SYSTEM ===== +const _I18N_VERSION = '20260604c'; // bump when translations change let _i18nStrings = null; // current language translations (flat) let _i18nFallback = null; // Italian fallback (flat) +let _i18nLoadedVersion = null; let _currentLang = localStorage.getItem('evershelf_lang') || navigator.language?.slice(0, 2) || 'en'; const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español' }; if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'en'; @@ -1156,15 +1160,17 @@ function t(key, params) { async function loadTranslations(lang) { lang = lang || _currentLang; try { - // Always load Italian as fallback - if (!_i18nFallback) { - const fbRes = await fetch(`translations/it.json?v=${Date.now()}`); + const bust = _I18N_VERSION; + // Reload fallback when version changes (avoids stale cached keys) + if (!_i18nFallback || _i18nLoadedVersion !== bust) { + const fbRes = await fetch(`translations/it.json?v=${bust}`); if (fbRes.ok) _i18nFallback = _flattenI18n(await fbRes.json()); + _i18nLoadedVersion = bust; } if (lang === 'it') { _i18nStrings = _i18nFallback; } else { - const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${Date.now()}`); + const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${bust}`); if (res.ok) _i18nStrings = _flattenI18n(await res.json()); else _i18nStrings = _i18nFallback; } @@ -2619,10 +2625,10 @@ async function _renderBackupTab() { if (data.last_backup_ts) { const secsAgo = Math.floor(Date.now() / 1000) - data.last_backup_ts; let ago; - if (secsAgo < 120) ago = secsAgo < 5 ? t('time.just_now') || 'adesso' : `${secsAgo}s fa`; - else if (secsAgo < 3600) ago = `${Math.floor(secsAgo / 60)} min fa`; - else if (secsAgo < 86400) ago = `${Math.floor(secsAgo / 3600)}h fa`; - else ago = `${Math.floor(secsAgo / 86400)}gg fa`; + if (secsAgo < 120) ago = secsAgo < 5 ? t('time.just_now') : t('time.seconds_ago', { n: secsAgo }); + else if (secsAgo < 3600) ago = t('time.minutes_ago', { n: Math.floor(secsAgo / 60) }); + else if (secsAgo < 86400) ago = t('time.hours_ago', { n: Math.floor(secsAgo / 3600) }); + else ago = t('time.days_ago', { n: Math.floor(secsAgo / 86400) }); const name = data.last_backup_file || ''; lastInfoEl.innerHTML = `${t('settings.backup.last_backup') || 'Ultimo backup'}: ${ago} (${name})`; } else { @@ -3363,7 +3369,7 @@ function _kioskReconfigureScale() { // Kiosk APK is outdated — show update notice const notice = document.getElementById('kiosk-needs-update-notice'); if (notice) notice.style.display = ''; - showToast('⚠️ Aggiorna il kiosk per usare questa funzione', 'warning'); + showToast(t('settings.kiosk_update_required'), 'warning'); } } @@ -4909,8 +4915,9 @@ async function loadDashboard() { }); // Load shopping list count from Bring! loadShoppingCount(); - // Show last known price total immediately from sessionStorage (before next background fetch) - _updateDashboardPriceTotal(); + // Show last known total instantly, then refresh from server + _loadCanonicalTotalFromSession(); + _applyShoppingTotalDisplay(); // Quick recipe button - show when there are expiring products const recipeBar = document.getElementById('quick-recipe-bar'); @@ -5365,10 +5372,9 @@ async function loadBannerAlerts() { _bannerQueue.push({ type: 'dup_loss_check', data: ch }); }); - // 7. Finished products: inventory hit 0, waiting for user confirmation + // 7. Unresolved ghosts: always show while server reports ledger/stock mismatch const finished = finishedData.finished || []; finished.forEach(fin => { - if (confirmed['fin_' + fin.product_id]) return; _bannerQueue.push({ type: 'finished', data: fin }); }); @@ -5626,15 +5632,16 @@ function renderBannerItem() { } else if (entry.type === 'finished') { const fin = entry.data; banner.className = 'alert-banner banner-finished'; - iconEl.textContent = '📦'; + iconEl.textContent = fin.vanished ? '👻' : '📦'; const barcodeSuffix = fin.barcode && fin.barcode.length >= 3 ? ` …${escapeHtml(fin.barcode.slice(-3))}` : ''; titleEl.innerHTML = `${escapeHtml(fin.name)}${fin.brand ? ' (' + escapeHtml(fin.brand) + ')' : ''}${barcodeSuffix} — ${escapeHtml(t('dashboard.banner_finished_title'))}`; const expectedText = fin.expected_qty ? ' ' + t('dashboard.banner_finished_expected', { qty: fin.expected_qty, unit: fin.unit }) : ''; - detailEl.innerHTML = t('dashboard.banner_finished_zero') + expectedText + ' ' + t('dashboard.banner_finished_check'); + const baseText = fin.vanished ? t('dashboard.banner_finished_vanished') : t('dashboard.banner_finished_zero'); + detailEl.innerHTML = baseText + expectedText + ' ' + t('dashboard.banner_finished_check'); let btns = ``; - btns += ``; + btns += ``; actionsEl.innerHTML = btns; } else if (entry.type === 'anomaly') { @@ -5750,7 +5757,7 @@ function confirmBannerPrediction() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'prediction') return; setReviewConfirmed('pred_' + entry.data.inventory_id); - showToast('✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni', 'success'); + showToast(t('dashboard.banner_prediction_confirmed'), 'success'); dismissBannerItem(); } @@ -5792,7 +5799,7 @@ async function explainBannerAnomaly() { detailEl.innerHTML = `\ud83e\udd16 ${escapeHtml(result.explanation)}`; } else { detailEl.innerHTML = originalHtml; - showToast('Impossibile ottenere spiegazione AI', 'error'); + showToast(t('dashboard.banner_anomaly_explain_fail'), 'error'); } } catch (e) { detailEl.innerHTML = originalHtml; @@ -5813,7 +5820,7 @@ function dismissBannerAnomaly() { const key = entry.data.dismiss_key; setReviewConfirmed('an_' + key); api('dismiss_anomaly', {}, 'POST', { dismiss_key: key }).catch(() => {}); - showToast('Anomalia ignorata', 'info'); + showToast(t('dashboard.banner_anomaly_dismissed'), 'info'); dismissBannerItem(); } @@ -5975,30 +5982,40 @@ async function confirmBannerFinished() { try { await api('inventory_confirm_finished', {}, 'POST', { product_id: productId }); } catch(e) {} - setReviewConfirmed('fin_' + productId); showToast(t('toast.product_finished_confirmed'), 'success'); dismissBannerItem(); + if (typeof loadDashboard === 'function') loadDashboard(); } async function notFinishedBannerAction() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'finished') return; - const productId = entry.data.product_id; - // Remove from this session's queue (will re-appear next load if still at qty=0) - dismissBannerItem(); + const fin = entry.data; + const qty = fin.expected_qty; + const customQty = prompt( + t('dashboard.banner_finished_restore_prompt', { name: fin.name, qty, unit: fin.unit }), + String(qty) + ); + if (customQty === null) return; + const parsed = parseFloat(String(customQty).replace(',', '.')); + if (isNaN(parsed) || parsed <= 0) { + showToast(t('error.invalid_quantity'), 'error'); + return; + } showLoading(true); try { - const data = await api('product_get', { id: productId }); - showLoading(false); - if (data.product) { - currentProduct = data.product; - showAddForm(); - } else { - showToast(t('error.not_found'), 'error'); - } - } catch(e) { - showLoading(false); + await api('inventory_restore_ghost', {}, 'POST', { + product_id: fin.product_id, + quantity: parsed, + location: fin.location || 'dispensa', + }); + showToast(t('toast.ghost_restored', { name: fin.name, qty: parsed, unit: fin.unit }), 'success'); + dismissBannerItem(); + if (typeof loadDashboard === 'function') loadDashboard(); + } catch (e) { showToast(t('error.connection'), 'error'); + } finally { + showLoading(false); } } @@ -6667,7 +6684,14 @@ function editInventoryItem(id) { const scaleEditReady = s.scale_enabled && s.scale_gateway_url && _scaleConnected && (effectiveUnit === 'g' || effectiveUnit === 'ml'); - window._editingProduct = { name: item.name, category: item.category || '', _isOpened: !!item.opened_at }; + window._editingProduct = { + name: item.name, + category: item.category || '', + _isOpened: !!item.opened_at, + _prevUnit: item.unit || 'pz', + default_quantity: item.default_quantity, + package_unit: item.package_unit || '', + }; // Rebuild modal content for editing (don't close and reopen - just replace content) document.getElementById('modal-content').innerHTML = ` @@ -6740,6 +6764,23 @@ function onEditUnitChange() { const unit = document.getElementById('edit-unit').value; const confGroup = document.getElementById('edit-conf-size-group'); if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none'; + if (unit === 'conf') { + const sizeInput = document.getElementById('edit-conf-size'); + const confUnitSel = document.getElementById('edit-conf-unit'); + if (sizeInput && !parseFloat(sizeInput.value)) { + const ep = window._editingProduct || {}; + const defQty = parseFloat(ep.default_quantity) || 0; + const pkgUnit = ep.package_unit || ''; + const prevUnit = ep._prevUnit || ''; + if (defQty > 0 && pkgUnit) { + sizeInput.value = defQty; + if (confUnitSel) confUnitSel.value = pkgUnit; + } else if (defQty > 0 && ['g', 'ml'].includes(prevUnit)) { + sizeInput.value = defQty; + if (confUnitSel) confUnitSel.value = prevUnit; + } + } + } } async function submitEditInventory(e, id, productId) { @@ -6749,6 +6790,15 @@ async function submitEditInventory(e, id, productId) { const expiry = document.getElementById('edit-expiry').value || null; const unit = document.getElementById('edit-unit').value; + if (unit === 'conf') { + const confSize = parseFloat(document.getElementById('edit-conf-size')?.value); + if (!confSize || confSize <= 0) { + showToast(t('product.conf_size_required'), 'error'); + document.getElementById('edit-conf-size')?.focus(); + return; + } + } + // 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 }; @@ -7533,7 +7583,7 @@ async function createQuickProduct(name) { }; showLoading(false); clearQuickNameResults(); - showToast('Prodotto creato!', 'success'); + showToast(t('toast.product_created'), 'success'); // If regex gave 'altro', try embedding in background and silently update if (category === 'altro' && typeof classifyCategoryByEmbedding === 'function') { @@ -7725,15 +7775,20 @@ function onCategoryChange(fromAutoDetect = false) { if (catDefaults[cat]) { // Only auto-fill unit/qty if user hasn't manually touched them - if (qtyInput.dataset.manuallySet !== 'true') { + const unitManuallySet = unitSelect.dataset.manuallySet === 'true'; + if (qtyInput.dataset.manuallySet !== 'true' && !unitManuallySet) { unitSelect.value = catDefaults[cat].unit; qtyInput.value = catDefaults[cat].qty; + } else if (qtyInput.dataset.manuallySet !== 'true' && unitManuallySet) { + qtyInput.value = catDefaults[cat].qty; } } } function onPfUnitChange() { - const unit = document.getElementById('pf-unit').value; + const unitEl = document.getElementById('pf-unit'); + if (unitEl) unitEl.dataset.manuallySet = 'true'; + const unit = unitEl?.value; const confRow = document.getElementById('pf-conf-size-row'); if (confRow) confRow.style.display = unit === 'conf' ? 'block' : 'none'; } @@ -7830,7 +7885,7 @@ async function scanBarcodeForForm() { document.getElementById('pf-barcode').value = code; _updateBarcodeHint(); if (navigator.vibrate) navigator.vibrate(80); - showToast(`🔖 Barcode acquisito: ${code}`, 'success'); + showToast(t('scan.barcode_acquired', { code }), 'success'); return; } } @@ -7869,7 +7924,7 @@ async function submitProduct(e) { if (result.success) { currentProduct = { ...productData, id: result.id }; showLoading(false); - showToast('Prodotto salvato!', 'success'); + showToast(t('toast.product_saved'), 'success'); showProductAction(); } else { showLoading(false); @@ -8409,7 +8464,7 @@ function showThrowForm() { api('inventory_list').then(data => { const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id); if (items.length === 0) { - showToast('Prodotto non nell\'inventario', 'error'); + showToast(t('error.not_in_inventory'), 'error'); return; } @@ -8812,7 +8867,7 @@ function recalculateAddExpiry() { const newLabel = formatEstimatedExpiry(days); let suffix = ''; - if (window._historyExpiryDays) suffix = ' (da storico)'; + if (window._historyExpiryDays) suffix = t('product.from_history'); else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum'); else if (loc === 'freezer') suffix = ' (freezer)'; else if (isVacuum) suffix = ' ' + t('add.suffix_vacuum'); @@ -8840,7 +8895,7 @@ async function _fetchExpiryHistoryAndUpdate(productId) { let days = isVacuum ? getVacuumExpiryDays(data.avg_days) : data.avg_days; const newDate = addDays(days); const newLabel = formatEstimatedExpiry(days); - const suffix = ` 📊 storico`; + const suffix = ` ${t('product.history_badge')}`; const expiryInput = document.getElementById('add-expiry'); const estimateEl = document.querySelector('.expiry-estimate-label'); const dateEl = document.querySelector('.expiry-estimate-date'); @@ -8917,7 +8972,7 @@ async function _applyAIProductHint() { // Show a toast only if location changed if (locChanged) { const locLabels = { dispensa: t('location.dispensa') || 'Dispensa', frigo: t('location.frigo') || 'Frigo', freezer: t('location.freezer') || 'Freezer' }; - showToast(`🤖 AI: conserva in ${locLabels[data.location] || data.location}`, 'info', 4000); + showToast(t('ai.conservation_hint', { location: locLabels[data.location] || data.location }), 'info', 4000); } } catch (e) { document.getElementById('ai-hint-loading')?.remove(); @@ -9041,7 +9096,7 @@ function selectPurchaseType(btn, type) { const estimatedDate = addDays(days); const estimateLabel = formatEstimatedExpiry(days); let suffix = ''; - if (window._historyExpiryDays) suffix = ` 📊 storico`; + if (window._historyExpiryDays) suffix = ` ${t('product.history_badge')}`; else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum'); else if (loc === 'freezer') suffix = ' ' + t('add.suffix_freezer'); else if (isVacuum) suffix = ' ' + t('add.suffix_vacuum'); @@ -9070,7 +9125,7 @@ function selectPurchaseType(btn, type) { -
${t('add.expiry_hint')}
+${t('product.expiry_hint')}
${t('use.disambiguation_hint')}
${locButtons} + ${oneConfBtn}