diff --git a/.github/workflows/build-kiosk.yml b/.github/workflows/build-kiosk.yml index 12c84bf..652bdea 100644 --- a/.github/workflows/build-kiosk.yml +++ b/.github/workflows/build-kiosk.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - develop paths: - 'evershelf-kiosk/**' workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b1b405..2b7bb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to EverShelf will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] - 2026-04-28 + +### Added +- **Expired banner for opened products** — Products whose opened-product shelf-life has passed (e.g. fridge cream opened 6 days ago) now appear in the top notification banner, not just the dashboard list +- **Safety-aware expired banner** — Each expired banner item shows a contextual safety tip (from `getExpiredSafety()`); danger-level items (fridge dairy/meat/fish) get an intense red banner and "L'ho buttato" as the primary button; safe/warning items keep the original button order +- **AI model fallback** — All Gemini API endpoints (expiry scan, product identification, chat, recipe non-streaming, shopping name classifier) now try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically, matching the resilience already in place for recipe streaming +- **Friendly AI quota message** — When the AI returns a quota/rate-limit error the user sees "Quota AI esaurita. Riprova tra qualche minuto." instead of the raw API error string +- **Cooking TTS auto-read** — Each recipe step is read aloud automatically when navigating forward or backward; the first step is also read when entering cooking mode +- **Cooking timer 10-second warning** — When a cooking timer reaches 10 seconds the TTS announces "Attenzione! [label]: mancano 10 secondi!" +- **Cooking recipe completion announcement** — "Ricetta completata! Buon appetito!" is spoken via TTS when the last step is confirmed + +### Fixed +- **Cooking TTS gate** — `speakCookingStep()` was blocked by the global `tts_enabled` setting; the `_cookingTTS` toggle (🔊/🔇 button) is now the only gate; browser Web Speech API is used by default without requiring TTS configuration in Settings +- **Anomaly dismiss label** — The "La quantità è giusta" button now appends the current inventory quantity, e.g. "La quantità è giusta (2 pz)", so the action is unambiguous +- **i18n sync** — Added `timer_warning_tts`, `recipe_done_tts`, `error.ai_quota` keys to all three language files (IT/EN/DE) + + +### Added +- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Pasta") rather than brand; computed via an expanded keyword map with Google Gemini AI as fallback for unknown products +- **Bring! auto-migration** — Existing list items with old specific names are silently migrated to generic names on every list load, throttled to once per 10 minutes +- **Bring! catalog coverage** — All 93 shopping_name values now resolve to a German Bring! catalog key (icons and categories in the Bring! app); 24 aliases added to cover previously unmatched names +- **Auto-add to Bring! on depletion** — When a product reaches zero the app adds it to Bring! automatically using the generic shopping name, with the specific product name and brand in the specification field +- **Finished-product confirmation banner** — Instead of silently deleting zero-stock entries, a banner prompts the user to confirm; banner title includes the last 3 digits of the product barcode for easier identification +- **Anomaly detection banner** — Dashboard notifications for suspicious inventory/transaction mismatches and consumption prediction errors, with one-tap inline correction +- **SSE recipe streaming** — Recipe generation streams live via Server-Sent Events; Gemini agent feedback is shown in real time as it is generated +- **Smart alert banners** — Configurable expired-only mode with explanatory messages; banner buttons are fully internationalized + +### Fixed +- **Scale double-deduction** — Multiple BLE stable readings of the same weight no longer fire duplicate `inventory_use` events; JS preserves the confirmation sentinel on submit and PHP rejects a second `out` transaction for the same product within 12 seconds +- **Kiosk native TTS** — CI workflow now builds the APK on `develop` branch too; the native Android `TextToSpeech` bridge bypasses Web Speech API voice-availability issues without requiring offline voice packs +- **TTS voice loading** — Retries for up to 10 seconds on page load; shows a message if no voices are available and offers a manual refresh button +- **Bring! migration** — Corrected two bugs: wrong removal API (`DELETE /item` → `PUT remove=item`) and wrong purchase key sent to Bring! (Italian shopping name → German catalog key), which previously created Italian/German duplicate entries +- **Gemini 429 rate limiting** — API calls are retried with exponential backoff; recipe requests are capped at 5 per minute with a dedicated rate-limit bucket + +### Performance +- **Gemini calls centralized** — All Gemini API requests go through a single `callGemini()` helper with intelligent backoff; Gemini removed from the product-selection and bringSuggest flows in favour of fast offline logic + ## [1.3.0] - 2026-04-18 ### Added diff --git a/README.md b/README.md index ac844f4..2cbbd41 100644 --- a/README.md +++ b/README.md @@ -16,37 +16,44 @@ ### 📦 Inventory Management - **Barcode scanning** — Scan products with your phone camera using QuaggaJS -- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory +- **AI identification** — Take a photo and let Google Gemini identify the product, with suggestions from your existing inventory; gracefully shows a friendly message when AI quota is exhausted instead of a raw API error - **Smart locations** — Track items across Pantry, Fridge, Freezer, and custom locations - **Expiry tracking** — Automatic shelf-life estimation based on product type and storage -- **Opened product tracking** — Reduced shelf-life calculation when packages are opened +- **Opened product tracking** — Reduced shelf-life calculation when packages are opened; opened-product expiry is now also checked when building banner alerts (not just the dashboard section) - **Vacuum-sealed support** — Extended expiry dates for vacuum-sealed items -- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction +- **Anomaly detection** — Banner alerts for suspicious quantities and consumption predictions with inline correction; dismiss button now shows the current inventory quantity so the action is unambiguous ("La quantità è giusta (2 pz)") ### 🤖 AI-Powered (Google Gemini) - **Expiry date reading** — Photograph a label and extract the expiry date automatically - **Product identification** — Point your camera at any product for instant recognition - **Existing product matching** — AI scan shows matching products already in your pantry before suggesting new ones -- **Recipe generation** — Get personalized recipes based on what's in your pantry +- **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 - **Smart chat assistant** — Ask questions about your inventory, get cooking tips - **Shopping suggestions** — AI-powered purchase recommendations +- **Model fallback** — All AI endpoints try `gemini-2.5-flash` first (separate quota) and fall back to `gemini-2.0-flash` automatically, matching the resilience already used for recipe generation ### 🛒 Shopping List - **Bring! integration** — Sync with the [Bring!](https://www.getbring.com/) shopping list app +- **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Panna da cucina") rather than brand, keeping the Bring! list clean and consolidated - **Smart predictions** — Know what you'll need before you run out -- **Auto-remove on scan** — Products are removed from the shopping list when scanned in -- **DupliClick integration** — Online grocery ordering (Gruppo Poli) +- **Auto-add on depletion** — When a product reaches zero the app adds it to Bring! automatically, no confirmation needed +- **Auto-remove on scan** — Products are removed from the shopping list when scanned in - **Auto-migration** — Items already on the Bring! list are silently renamed to their generic name in the background (throttled, runs on list load) + - **Catalog coverage** — All product types resolve to a German Bring! catalog key for icon and category display in the Bring! app- **DupliClick integration** — Online grocery ordering (Gruppo Poli) ### 🍳 Cooking Mode - **Step-by-step guidance** — Follow recipes with a hands-free cooking interface -- **Text-to-Speech** — Voice readout of recipe steps (configurable TTS endpoint) +- **Text-to-Speech** — Voice readout of recipe steps; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.); retries voice loading for up to 10 seconds with a fallback refresh button; TTS activates automatically without requiring the global TTS setting to be enabled +- **Auto-read on navigate** — Each step is read aloud automatically when you tap Next or Previous; the first step is read when entering cooking mode +- **Timer voice alerts** — 10-second countdown warning spoken aloud before each timer expires; expiry announced vocally when time is up +- **Recipe completion** — "Buon appetito!" spoken when the last step is confirmed - **Built-in timer** — Automatic timer suggestions based on recipe instructions -- **Ingredient tracking** — Mark ingredients as used during cooking +- **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow ### 📊 Dashboard - **Waste tracking** — Monitor consumed vs. wasted products over 30 days - **Expiry alerts** — Visual warnings for expired and soon-to-expire items -- **Safety ratings** — Smart assessment of expired product safety (by category) +- **Safety ratings** — Smart assessment of expired product safety (by category and location); expired unsafe items shown with a red danger banner and "L'ho buttato" as the primary action +- **Expired product banner** — Products that have passed their effective shelf-life (including opened-product reduced expiry) appear in the top notification banner with safety tip, danger styling for high-risk items, and a prominent discard action - **Quick recipe bar** — One-tap recipe suggestion using expiring products - **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit - **Expired/expiring alerts** — Priority-sorted banner notifications for expired and soon-to-expire products with use, throw, edit, and dismiss actions @@ -63,8 +70,7 @@ - **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues - **Auto-discovery** — Server scans LAN to find the gateway automatically - **Auto weight reading** — When adding/using a product with unit g/ml, weight fills automatically -- **10g threshold** — Ignores readings that haven't changed enough between products -- **ml conversion hint** — Shows "weight in grams → will be converted to ml" when product unit is ml +- **10g threshold** — Ignores readings that haven't changed enough between products - **Duplicate-reading prevention** — Server-side 12-second dedup window rejects a second scale-triggered deduction of the same product, guarding against BLE multi-fire- **ml conversion hint** — Shows "weight in grams → will be converted to ml" when product unit is ml - **Stability + auto-confirm** — 10s stable wait + 5s countdown before confirming - **Real-time status** — Scale connection indicator always visible in the header - **Multi-protocol** — Supports Bluetooth SIG Weight Scale, Body Composition, Xiaomi Mi Scale 2 and 100+ models @@ -76,6 +82,7 @@ - **Setup wizard** — 3-step guided configuration (URL, connection test, gateway) - **Gateway auto-launch** — Launches the Scale Gateway in the background on startup - **Camera & mic permissions** — Full hardware access for barcode scanning and voice +- **Native TTS bridge** — Cooking mode voice readout uses the Android TextToSpeech engine directly, bypassing Web Speech API voice limitations; no offline voice packs required - **Hard refresh** — ↻ button clears WebView cache to pick up web app updates - **Update notifications** — Checks GitHub releases every 6h, shows banner when updates available - **SSL support** — Accepts self-signed certificates @@ -313,6 +320,9 @@ The application uses no build tools — edit files directly and refresh. - [x] AI scan local matching — suggest existing pantry products before OFF lookup - [x] Scale auto-fill improvements — 10g threshold, ml conversion hints - [x] Update notification system — kiosk checks GitHub releases +- [x] Generic shopping name grouping — compound-phrase + keyword map (100+ entries) + Gemini AI fallback +- [x] Auto-add to Bring! on product depletion — no confirmation step when stock reaches zero +- [x] Native Android TTS in kiosk — bypasses Web Speech API voice detection issues - [ ] Offline mode with service worker - [ ] Export/import inventory data - [ ] Notification system (Telegram, email) for expiring products diff --git a/api/index.php b/api/index.php index d7aee5a..9b30d2a 100644 --- a/api/index.php +++ b/api/index.php @@ -66,11 +66,16 @@ function checkRateLimit(string $action): void { // Determine limit based on action $aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping']; $loginActions = ['dupliclick_login']; + $recipeActions = ['generate_recipe', 'generate_recipe_stream']; if (in_array($action, $aiActions)) { $limit = 15; $window = 60; $bucket = 'ai'; + } elseif (in_array($action, $recipeActions)) { + $limit = 5; + $window = 60; + $bucket = 'recipe'; } elseif (in_array($action, $loginActions)) { $limit = 5; $window = 60; @@ -175,6 +180,12 @@ try { case 'inventory_delete': deleteInventory($db); break; + case 'inventory_finished_items': + getFinishedItems($db); + break; + case 'inventory_confirm_finished': + confirmFinished($db); + break; case 'inventory_summary': inventorySummary($db); break; @@ -197,6 +208,14 @@ try { getConsumptionPredictions($db); break; + case 'inventory_anomalies': + getInventoryAnomalies($db); + break; + + case 'dismiss_anomaly': + dismissInventoryAnomaly(); + break; + case 'recent_popular_products': recentPopularProducts($db); break; @@ -210,6 +229,10 @@ try { generateRecipe($db); break; + case 'generate_recipe_stream': + generateRecipeStream($db); + break; + case 'gemini_identify': geminiIdentifyProduct(); break; @@ -231,6 +254,9 @@ try { case 'bring_clean_specs': bringCleanSpecs(); break; + case 'bring_migrate_names': + bringMigrateNames($db); + break; case 'bring_suggest': bringSuggestItems($db); break; @@ -610,32 +636,39 @@ function saveProduct(PDO $db): void { return; } + // Auto-compute shopping_name unless the caller explicitly provides one. + // A caller may pass shopping_name=null or omit it to always trigger auto-compute. + $shoppingName = array_key_exists('shopping_name', $input) && $input['shopping_name'] !== null && $input['shopping_name'] !== '' + ? $input['shopping_name'] + : computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? ''); + if (!empty($input['id'])) { // Update existing $stmt = $db->prepare(" - UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, - default_quantity=?, notes=?, barcode=?, package_unit=?, updated_at=CURRENT_TIMESTAMP - WHERE id=? + UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, + default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?, + updated_at=CURRENT_TIMESTAMP WHERE id=? "); $stmt->execute([ $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', - $input['barcode'] ?? null, $input['package_unit'] ?? '', $input['id'] + $input['barcode'] ?? null, $input['package_unit'] ?? '', + $shoppingName, $input['id'] ]); echo json_encode(['success' => true, 'id' => $input['id']]); } else { // Insert new $stmt = $db->prepare(" - INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit, shopping_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $barcode = !empty($input['barcode']) ? $input['barcode'] : null; $stmt->execute([ $barcode, $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, - $input['notes'] ?? '', $input['package_unit'] ?? '' + $input['notes'] ?? '', $input['package_unit'] ?? '', $shoppingName ]); echo json_encode(['success' => true, 'id' => $db->lastInsertId()]); } @@ -684,10 +717,11 @@ function listInventory(PDO $db): void { COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at FROM inventory i JOIN products p ON i.product_id = p.id + WHERE i.quantity > 0 "; $params = []; if (!empty($location)) { - $query .= " WHERE i.location = ?"; + $query .= " AND i.location = ?"; $params[] = $location; } $query .= " ORDER BY p.name ASC"; @@ -784,14 +818,18 @@ function addToInventory(PDO $db): void { // Auto-remove from Bring! if product is on the shopping list $removedFromBring = false; try { - $stmt = $db->prepare("SELECT name FROM products WHERE id = ?"); + $stmt = $db->prepare("SELECT name, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); - $prodName = $stmt->fetchColumn(); - if ($prodName) { + $prod = $stmt->fetch(); + if ($prod) { + $prodName = $prod['name']; + // Use shopping_name for Bring! removal — Bring! was added with the generic name + $displayName = $prod['shopping_name'] ?: computeShoppingName($prodName); $auth = bringAuth(); if ($auth) { $listUUID = $auth['bringListUUID']; - $bringKey = italianToBring($prodName); + // Primary Bring! key: catalog key of the generic shopping name + $bringKey = italianToBring($displayName); $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); if ($listData && isset($listData['purchase'])) { // Token-based matching — same logic as _productOnBring() in smart_shopping @@ -804,28 +842,35 @@ function addToInventory(PDO $db): void { fn($t) => mb_strlen($t) > 2 && !in_array($t, $stop) )); }; - $prodTokens = $tokenize($prodName); - $keyTokens = $tokenize($bringKey); - $prodFirst = $prodTokens[0] ?? ''; - $keyFirst = $keyTokens[0] ?? ''; + // Tokens from both the generic name and the specific product name + $displayTokens = $tokenize($displayName); + $prodTokens = $tokenize($prodName); + $keyTokens = $tokenize($bringKey); + $displayFirst = $displayTokens[0] ?? ''; + $prodFirst = $prodTokens[0] ?? ''; + $keyFirst = $keyTokens[0] ?? ''; foreach ($listData['purchase'] as $item) { $rawName = $item['name'] ?? ''; - // 1. Exact match on translated catalog key or original Italian name - if (strcasecmp($rawName, $bringKey) === 0 || strcasecmp($rawName, $prodName) === 0) { + // 1. Exact match on catalog key, generic name, or specific product name + if (strcasecmp($rawName, $bringKey) === 0 + || strcasecmp($rawName, $displayName) === 0 + || strcasecmp($rawName, $prodName) === 0) { bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", http_build_query(['uuid' => $listUUID, 'remove' => $rawName])); $removedFromBring = true; break; } - // 2. Token-based fuzzy: first significant word must match - if ($prodFirst || $keyFirst) { + // 2. Token-based fuzzy: first significant word must match any of our names + if ($displayFirst || $prodFirst || $keyFirst) { $rawTokens = $tokenize($rawName); $rawFirst = $rawTokens[0] ?? ''; if ($rawFirst && ( - $rawFirst === $prodFirst || - $rawFirst === $keyFirst || - in_array($prodFirst, $rawTokens) || - in_array($keyFirst, $rawTokens) + $rawFirst === $displayFirst || + $rawFirst === $prodFirst || + $rawFirst === $keyFirst || + in_array($displayFirst, $rawTokens) || + in_array($prodFirst, $rawTokens) || + in_array($keyFirst, $rawTokens) )) { bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", http_build_query(['uuid' => $listUUID, 'remove' => $rawName])); @@ -867,6 +912,30 @@ function useFromInventory(PDO $db): void { echo json_encode(['error' => 'Product ID required']); return; } + + // ── Server-side deduplication ───────────────────────────────────────── + // Reject if the same product already has an 'out' transaction in the last + // 12 seconds. This guards against scale double-triggers (the scale can fire + // a second stable reading ~10 s after the first auto-confirm, while the + // product is still on the plate), regardless of the client-side guard. + if (!$useAll) { + $dedup = $db->prepare( + "SELECT id FROM transactions + WHERE product_id = ? AND type IN ('out','waste') AND undone = 0 + AND created_at >= datetime('now', '-12 seconds') + LIMIT 1" + ); + $dedup->execute([$productId]); + if ($dedup->fetch()) { + echo json_encode([ + 'success' => false, + 'error' => 'Operazione già registrata di recente — attendi qualche secondo.', + 'duplicate' => true, + ]); + return; + } + } + // ───────────────────────────────────────────────────────────────────── // Handle "throw all from all locations" if ($useAll && $location === '__all__') { @@ -876,7 +945,7 @@ function useFromInventory(PDO $db): void { $totalRemoved = 0; foreach ($allItems as $item) { $totalRemoved += $item['quantity']; - $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$item['id']]); $type = ($notes === 'Buttato') ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); @@ -954,7 +1023,7 @@ function useFromInventory(PDO $db): void { $actualDeducted = min($quantity, $existing['quantity']); if ($newQty <= 0) { - $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); + $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$existing['id']]); } else { // Check if item is now opened (first use reduces quantity) @@ -1016,8 +1085,8 @@ function useFromInventory(PDO $db): void { $totalLeft = (float)($stmt->fetchColumn() ?: 0); if ($totalLeft <= 0) { - // Get product name and brand for Bring! - $stmt = $db->prepare("SELECT name, brand FROM products WHERE id = ?"); + // Get product name, brand and shopping_name for Bring! + $stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $product = $stmt->fetch(); @@ -1026,7 +1095,9 @@ function useFromInventory(PDO $db): void { $auth = bringAuth(); if ($auth) { $listUUID = $auth['bringListUUID']; - $bringName = italianToBring($product['name']); + // Use the generic shopping name for Bring! (e.g. "Latte", "Affettato") + $genericName = $product['shopping_name'] ?: computeShoppingName($product['name'], '', $product['brand']); + $bringName = italianToBring($genericName); // Check if already on the Bring! list $alreadyOnList = false; @@ -1044,8 +1115,10 @@ function useFromInventory(PDO $db): void { // Already on the list, skip adding $addedToBring = false; } else { - // Build specification from product name (variant info, not brand) - $spec = $product['name'] ? $product['name'] : ''; + // Specification: specific product name (and brand) so the user knows which variant + $spec = $genericName !== $product['name'] + ? $product['name'] . ($product['brand'] ? ' · ' . $product['brand'] : '') + : ($product['brand'] ?: $product['name']); $body = http_build_query([ 'uuid' => $listUUID, 'purchase' => $bringName, @@ -1074,7 +1147,7 @@ function useFromInventory(PDO $db): void { $totalRemaining = round((float)($stmt->fetchColumn() ?: 0), 6); // Get product info for low-stock prompt - $stmt = $db->prepare("SELECT name, brand, unit, default_quantity, package_unit FROM products WHERE id = ?"); + $stmt = $db->prepare("SELECT name, brand, unit, default_quantity, package_unit, shopping_name FROM products WHERE id = ?"); $stmt->execute([$productId]); $prodInfo = $stmt->fetch(); @@ -1086,6 +1159,9 @@ function useFromInventory(PDO $db): void { $response['product_unit'] = $prodInfo['unit']; $response['product_default_qty'] = (float)($prodInfo['default_quantity'] ?: 0); $response['product_package_unit'] = $prodInfo['package_unit'] ?: ''; + // Generic shopping name for Bring! (e.g. "Affettato" for "Mortadella IGP") + $shopping = $prodInfo['shopping_name'] ?: computeShoppingName($prodInfo['name'], '', $prodInfo['brand']); + $response['product_shopping_name'] = $shopping; } if ($openedId) $response['opened_id'] = $openedId; echo json_encode($response); @@ -1132,6 +1208,80 @@ function deleteInventory(PDO $db): void { echo json_encode(['success' => true]); } +/** + * Returns products whose entire inventory is at quantity = 0 AND whose + * transaction balance (total_in - total_out) is still significantly positive — + * meaning the system suspects the product ran out prematurely (scale drift, + * missed registration, etc.). + * + * Products where the balance is at/near zero are legitimately finished by the + * user; those rows are silently deleted here (no banner needed). + */ +function getFinishedItems(PDO $db): void { + $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 + 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 + ")->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; + + if ($expected > $threshold) { + // Transaction balance says stock should remain — show banner + $suspicious[] = [ + 'product_id' => (int)$r['product_id'], + 'name' => $r['name'], + 'brand' => $r['brand'], + 'unit' => $r['unit'], + 'default_quantity' => $r['default_quantity'], + 'package_unit' => $r['package_unit'], + 'image_url' => $r['image_url'], + 'barcode' => $r['barcode'], + 'location' => $r['location'], + 'updated_at' => $r['updated_at'], + 'expected_qty' => round($expected, 3), + ]; + } else { + // Legitimately finished — delete silently, no banner + $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0") + ->execute([$r['product_id']]); + } + } + + echo json_encode(['success' => true, 'finished' => $suspicious], JSON_UNESCAPED_UNICODE); +} + +/** + * Permanently delete all qty=0 inventory rows for a product after user confirms it is finished. + */ +function confirmFinished(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $productId = (int)($input['product_id'] ?? 0); + if (!$productId) { + http_response_code(400); + echo json_encode(['error' => 'product_id required']); + return; + } + $db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")->execute([$productId]); + echo json_encode(['success' => true]); +} + function inventorySummary(PDO $db): void { $stmt = $db->query(" SELECT i.location, COUNT(DISTINCT i.product_id) as product_count, @@ -1256,6 +1406,100 @@ function undoTransaction(PDO $db): void { // ===== STATS ===== +/** + * Detect inventory items where the stored quantity is significantly inconsistent + * with the transaction history (sum of in - sum of out/waste). + * + * Two anomaly directions: + * - PHANTOM (+diff): inventory > tx balance → quantity was manually inflated without an 'in' tx + * - MISSING (-diff): inventory < tx balance → tx history says more should be here than stored + */ +function getInventoryAnomalies(PDO $db): void { + $rows = $db->query(" + SELECT p.id AS product_id, p.name, p.brand, p.unit, + p.default_quantity, p.package_unit, + i.id AS inventory_id, i.quantity AS inv_qty, i.location, + COALESCE(tx_in.tot, 0) AS total_in, + COALESCE(tx_out.tot, 0) AS total_out + FROM inventory i + JOIN products p ON p.id = i.product_id + LEFT JOIN ( + SELECT product_id, SUM(quantity) AS tot + FROM transactions WHERE type = 'in' AND undone = 0 GROUP BY product_id + ) tx_in ON tx_in.product_id = p.id + LEFT JOIN ( + SELECT product_id, SUM(quantity) AS tot + FROM transactions WHERE type IN ('out','waste') AND undone = 0 GROUP BY product_id + ) tx_out ON tx_out.product_id = p.id + WHERE i.quantity > 0 + ")->fetchAll(PDO::FETCH_ASSOC); + + // Anomaly dismissed keys stored in a simple JSON file + $dismissFile = __DIR__ . '/../data/anomaly_dismissed.json'; + $dismissed = []; + if (file_exists($dismissFile)) { + $dismissed = json_decode(file_get_contents($dismissFile), true) ?: []; + } + + $anomalies = []; + foreach ($rows as $r) { + $invQty = floatval($r['inv_qty']); + $expected = floatval($r['total_in']) - floatval($r['total_out']); + $diff = $invQty - $expected; + + // Threshold: difference must be >20% of inventory AND >50 units (avoid noise) + $threshold = max(1.0, $invQty * 0.20); + if (abs($diff) <= $threshold || abs($diff) <= 50) continue; + + // Dismiss key: product_id + rounded expected (so re-adding stock resets the alert) + $key = 'a_' . $r['product_id'] . '_' . round($expected); + if (!empty($dismissed[$key])) continue; + + $direction = $diff > 0 ? 'phantom' : 'missing'; + $anomalies[] = [ + 'inventory_id' => (int)$r['inventory_id'], + 'product_id' => (int)$r['product_id'], + 'name' => $r['name'], + 'brand' => $r['brand'] ?: '', + 'unit' => $r['unit'], + 'default_quantity' => $r['default_quantity'], + 'package_unit' => $r['package_unit'], + 'inv_qty' => round($invQty, 2), + 'expected_qty' => round($expected, 2), + 'diff' => round($diff, 2), + 'direction' => $direction, + 'dismiss_key' => $key, + ]; + } + + // Sort: largest absolute diff first + usort($anomalies, fn($a, $b) => abs($b['diff']) <=> abs($a['diff'])); + + echo json_encode(['success' => true, 'anomalies' => $anomalies], JSON_UNESCAPED_UNICODE); +} + +/** + * Dismiss a specific anomaly so it no longer appears in the banner. + */ +function dismissInventoryAnomaly(): void { + $input = json_decode(file_get_contents('php://input'), true); + $key = $input['dismiss_key'] ?? ''; + if (empty($key) || !preg_match('/^a_\d+_-?\d+$/', $key)) { + echo json_encode(['success' => false, 'error' => 'Invalid key']); + return; + } + $dismissFile = __DIR__ . '/../data/anomaly_dismissed.json'; + $dismissed = []; + if (file_exists($dismissFile)) { + $dismissed = json_decode(file_get_contents($dismissFile), true) ?: []; + } + $dismissed[$key] = time(); + // Clean up entries older than 90 days + $dismissed = array_filter($dismissed, fn($ts) => $ts > time() - 90 * 86400); + file_put_contents($dismissFile, json_encode($dismissed), LOCK_EX); + echo json_encode(['success' => true]); +} + function getStats(PDO $db): void { $totalProducts = $db->query("SELECT COUNT(*) FROM products")->fetchColumn(); $totalItems = $db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory")->fetchColumn(); @@ -1523,16 +1767,19 @@ function getConsumptionPredictions(PDO $db): void { } $predictions[] = [ - 'inventory_id' => (int)$item['inventory_id'], - 'product_id' => (int)$item['product_id'], - 'name' => $item['name'], - 'brand' => $item['brand'], - 'location' => $item['location'], - 'unit' => $displayUnit, - 'expected_qty' => $expDisplay, - 'actual_qty' => $actDisplay, - 'daily_rate' => round($dailyRate, 3), - 'deviation_pct'=> round($pctDev * 100), + 'inventory_id' => (int)$item['inventory_id'], + 'product_id' => (int)$item['product_id'], + 'name' => $item['name'], + 'brand' => $item['brand'], + 'location' => $item['location'], + 'unit' => $displayUnit, + 'expected_qty' => $expDisplay, + 'actual_qty' => $actDisplay, + 'daily_rate' => round($dailyRate, 3), + 'deviation_pct' => round($pctDev * 100), + 'days_since_restock' => (int)round($daysSinceRestock), + 'direction' => $actualQty > $expectedQty ? 'more' : 'less', + 'tx_count' => count($rows), ]; } } @@ -1656,6 +1903,92 @@ function saveSettings(): void { // ===== GEMINI AI FUNCTIONS ===== +/** + * Calls the Gemini REST API with exponential backoff on 429 / 503. + * - Reads Google's Retry-After response header. + * - Reads Google's retryDelay field inside the error body (e.g. "10s"). + * - Up to 4 attempts; default wait sequence: 2 s, 4 s, 8 s. + * + * @return array{http_code:int, body:string, data:array|null} + */ +function callGemini(string $url, array $payload, int $timeout = 60): array { + $maxAttempts = 4; + $lastCode = 0; + $lastBody = ''; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $retryAfterHeader = null; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + // Capture response headers to read Retry-After + CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$retryAfterHeader) { + if (stripos($header, 'retry-after:') === 0) { + $val = intval(trim(substr($header, strlen('retry-after:')))); + if ($val > 0) $retryAfterHeader = $val; + } + return strlen($header); + }, + ]); + + $body = curl_exec($ch); + $lastCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($body !== false) $lastBody = $body; + + // Success or non-retryable error → stop immediately + if ($lastCode === 200) break; + if ($lastCode !== 429 && $lastCode !== 503) break; + if ($attempt >= $maxAttempts) break; + + // Determine how long to wait ----------------------------------------------- + // Priority 1: Retry-After header (set by Google in some 429 responses) + $waitSec = $retryAfterHeader ?? ($attempt * 2); // default: 2 s, 4 s, 6 s + + // Priority 2: Google's retryDelay inside the error body (e.g. {"retryDelay":"10s"}) + if ($body) { + $errData = json_decode($body, true); + foreach (($errData['error']['details'] ?? []) as $detail) { + if (!empty($detail['retryDelay'])) { + $parsed = intval(preg_replace('/\D/', '', $detail['retryDelay'])); + if ($parsed > 0) { $waitSec = min($parsed, 60); break; } + } + } + } + + sleep($waitSec); + } + + return [ + 'http_code' => $lastCode, + 'body' => $lastBody, + 'data' => $lastBody ? json_decode($lastBody, true) : null, + ]; +} + +/** + * Like callGemini() but tries gemini-2.5-flash first, falls back to gemini-2.0-flash + * on quota/rate-limit errors (429/503). Builds the URL from model name + API key. + */ +function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 30): array { + $models = ['gemini-2.5-flash', 'gemini-2.0-flash']; + $last = ['http_code' => 0, 'body' => '', 'data' => null]; + foreach ($models as $model) { + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + $last = callGemini($url, $payload, $timeout); + if ($last['http_code'] === 200) return $last; + if ($last['http_code'] !== 429 && $last['http_code'] !== 503) return $last; // non-retryable + // 429/503 on this model → try next model + } + return $last; +} + function geminiReadExpiry(): void { $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { @@ -1672,8 +2005,6 @@ function geminiReadExpiry(): void { } // Call Gemini API - $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; - $payload = [ 'contents' => [ [ @@ -1696,27 +2027,16 @@ function geminiReadExpiry(): void { ] ]; - $jsonPayload = json_encode($payload); - - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $jsonPayload, - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($response === false || $httpCode !== 200) { - echo json_encode(['success' => false, 'error' => 'Gemini API error', 'http_code' => $httpCode]); + $result = callGeminiWithFallback($apiKey, $payload, 30); + $httpCode = $result['http_code']; + + if ($httpCode !== 200) { + $errMsg = $result['data']['error']['message'] ?? 'Gemini API error'; + echo json_encode(['success' => false, 'error' => $errMsg, 'http_code' => $httpCode]); return; } - - $data = json_decode($response, true); + + $data = $result['data']; $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; // Parse the JSON response from Gemini @@ -1856,8 +2176,6 @@ PROMPT; 'parts' => [['text' => $message]] ]; - $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; - $payload = [ 'contents' => $contents, 'generationConfig' => [ @@ -1866,26 +2184,16 @@ PROMPT; ] ]; - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode($payload), - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 60, - ]); + $result = callGeminiWithFallback($apiKey, $payload, 60); + $httpCode = $result['http_code']; - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($response === false || $httpCode !== 200) { - echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode]); + if ($httpCode !== 200) { + $errMsg = $result['data']['error']['message'] ?? 'Errore API Gemini'; + echo json_encode(['success' => false, 'error' => $errMsg, 'http_code' => $httpCode]); return; } - $data = json_decode($response, true); - $reply = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $reply = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; if (empty($reply)) { echo json_encode(['success' => false, 'error' => 'Risposta vuota da Gemini']); @@ -2267,8 +2575,6 @@ Rispondi SOLO JSON valido (no markdown): {"title":"…","meal":"$mealType","persons":$persons,"prep_time":"…","cook_time":"…","tags":["…"],"expiry_note":"…","ingredients":[{"name":"…","qty":"200 g","qty_number":200,"from_pantry":true}],"steps":["Passo 1…"],"nutrition_note":"…"} PROMPT; - $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; - $payload = [ 'contents' => [ [ @@ -2283,30 +2589,16 @@ PROMPT; ] ]; - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode($payload), - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 60, - ]); + $result = callGeminiWithFallback($apiKey, $payload, 60); + $httpCode = $result['http_code']; - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($response === false || $httpCode !== 200) { - $errDetail = ''; - if ($response) { - $errData = json_decode($response, true); - $errDetail = $errData['error']['message'] ?? substr($response, 0, 300); - } + if ($httpCode !== 200) { + $errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300); echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode, 'detail' => $errDetail]); return; } - $data = json_decode($response, true); + $data = $result['data']; $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; // Clean markdown wrapping @@ -2513,6 +2805,518 @@ PROMPT; } } +// ===== RECIPE GENERATION — STREAMING AGENT ===== +function generateRecipeStream(PDO $db): void { + // Override content-type for SSE before any output is sent + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('X-Accel-Buffering: no'); + header('Content-Encoding: identity'); + set_time_limit(600); // up to 10 min: worst-case 2 models x 2 retries x 90s wait + generation time + ignore_user_abort(true); + while (ob_get_level() > 0) ob_end_clean(); + + $send = function(string $type, array $data): void { + echo 'data: ' . json_encode(['type' => $type] + $data, JSON_UNESCAPED_UNICODE) . "\n\n"; + flush(); + }; + + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) { $send('error', ['error' => 'no_api_key']); return; } + + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $mealType = $input['meal'] ?? 'pranzo'; + $persons = max(1, intval($input['persons'] ?? 1)); + $subType = $input['sub_type'] ?? ''; + $options = $input['options'] ?? []; + $appliances = $input['appliances'] ?? []; + $dietaryRestrictions = $input['dietary_restrictions'] ?? ''; + $todayRecipes = $input['today_recipes'] ?? []; + $mealPlanType = $input['meal_plan_type'] ?? ''; + $variation = max(0, intval($input['variation'] ?? 0)); + $rejectedIngredients = $input['rejected_ingredients'] ?? []; + + // ── AGENTE PASSO 1: Analisi dispensa ───────────────────────────────────── + $send('status', ['step' => 1, 'message' => '📦 Analizzo la dispensa...']); + + $stmt = $db->query(" + SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, + CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left + FROM inventory i + JOIN products p ON p.id = i.product_id + WHERE i.quantity > 0 + ORDER BY days_left ASC + "); + $items = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (empty($items)) { $send('error', ['error' => 'La dispensa è vuota!']); return; } + + $getItemPriority = function($item): int { + $daysLeft = floatval($item['days_left']); + $isOpen = !empty($item['opened_at']) || + (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf'); + if (!empty($item['expiry_date']) && $daysLeft < 0) return 1; + if (!empty($item['expiry_date']) && $daysLeft <= 3) return 2; + if (!empty($item['expiry_date']) && $daysLeft <= 7) return 3; + if (!empty($item['expiry_date'])) return 4; + if ($isOpen) return 5; + return 6; + }; + + usort($items, function($a, $b) use ($getItemPriority) { + $pa = $getItemPriority($a); $pb = $getItemPriority($b); + if ($pa !== $pb) return $pa - $pb; + return floatval($a['days_left']) - floatval($b['days_left']); + }); + + $staplePatterns = '/\b(sale|pepe|olio d.oliva|olio di semi|olio extra|acqua|aceto balsamico|aceto di|sel marin)\b/i'; + $priorityGroups = []; + foreach ($items as $item) { + $group = $getItemPriority($item); + if ($group >= 5 && preg_match($staplePatterns, $item['name'])) continue; + $qty = floatval($item['quantity']); + $isOpen = !empty($item['opened_at']) || ($qty > 0 && $qty < 1 && $item['unit'] === 'conf'); + $daysLeft = intval($item['days_left']); + $line = "- {$item['name']}: {$item['quantity']} {$item['unit']}"; + if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) + $line .= " ({$item['default_quantity']}{$item['package_unit']}/conf)"; + // Annotazioni urgenza: solo gruppi 1-3 (riduce token per gruppi 4-6) + if ($group <= 3 && $item['expiry_date']) { + if ($daysLeft < 0) $line .= ' ⚠️SCADUTO'; + elseif ($daysLeft <= 3) $line .= " 🔴{$daysLeft}gg"; + else $line .= " 🟠{$daysLeft}gg"; + } + if ($isOpen && $group <= 5) $line .= ' [APERTO]'; + $priorityGroups[$group][] = $line; + } + + // Limiti ingredienti per gruppo: con piano pasto attivo passa TUTTO (l'AI deve combinare liberamente) + // Senza piano pasto: limiti moderati per ridurre token (ora safe grazie a thinkingBudget:0) + $hasMealPlan = !empty($mealPlanType); + $ingredientSections = []; + $priorityHeaders = [1=>'SCADUTI — usa subito',2=>'SCADENZA ≤3gg — priorità alta',3=>'SCADENZA ≤7gg',4=>'ALTRI CON SCADENZA',5=>'APERTI',6=>'DISPENSA']; + $totalIngredientsSent = 0; + foreach ($priorityHeaders as $g => $header) { + if (empty($priorityGroups[$g])) continue; + $gi = $priorityGroups[$g]; + if (!$hasMealPlan) { + // Senza piano: limiti moderati + if ($g === 4 && count($gi) > 25) $gi = array_slice($gi, 0, 25); + if ($g === 6 && count($gi) > 15) $gi = array_slice($gi, 0, 15); + } + // Con piano pasto attivo: nessun limite — tutti gli ingredienti disponibili + $ingredientSections[] = "[$header]\n" . implode("\n", $gi); + $totalIngredientsSent += count($gi); + } + $ingredientsText = implode("\n", $ingredientSections); + + // Inventory status event + $urgentCount = count($priorityGroups[1] ?? []) + count($priorityGroups[2] ?? []); + if ($urgentCount > 0) { + $urgentRaw = array_merge($priorityGroups[1] ?? [], $priorityGroups[2] ?? []); + $urgentNames = array_slice(array_map( + fn($l) => trim(preg_replace('/\s[\[\x{26A0}\x{1F534}\x{1F7E0}].*/u', '', explode(':', ltrim($l, '- '))[0])), + $urgentRaw), 0, 3); + $send('status', ['step' => 1, 'message' => "⚠️ {$urgentCount} urgenti: " . implode(', ', $urgentNames)]); + } else { + $countMsg = count($items) . ' prodotti trovati'; + if ($hasMealPlan && $totalIngredientsSent < count($items)) { + $countMsg .= " ({$totalIngredientsSent} passati all'AI)"; + } elseif ($hasMealPlan) { + $countMsg .= ' — tutti passati all\'AI'; + } + $send('status', ['step' => 1, 'message' => '✅ ' . $countMsg]); + } + + // Mandatory/recommended items + $mandatoryItems = []; + $recommendedItems = []; + $wantsExpiryPriority = in_array('scadenze', $options) || in_array('zerowaste', $options); + $wantsOpenedPriority = in_array('opened', $options); + if ($wantsExpiryPriority || $wantsOpenedPriority) { + foreach ($items as $item) { + $g = $getItemPriority($item); + $daysLeft = floatval($item['days_left']); + $isOpen = !empty($item['opened_at']) || + (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf'); + $expiryNote = !empty($item['expiry_date']) ? " — scade: {$item['expiry_date']}" : ''; + $openNote = $isOpen ? ' [APERTO]' : ''; + $label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . $openNote . $expiryNote; + if ($wantsExpiryPriority) { + if ($g === 1 || $g === 2) $mandatoryItems[] = $label; + elseif ($g === 3) $recommendedItems[] = $label; + } + if (($wantsOpenedPriority || $wantsExpiryPriority) && $isOpen && $daysLeft <= 7 && $daysLeft >= 0) { + if (!in_array($label, $mandatoryItems) && !in_array($label, $recommendedItems)) + $recommendedItems[] = $label; + } + } + } + $mustUseText = ''; + if (!empty($mandatoryItems)) $mustUseText .= "\n\n⚠️ OBBLIGATORI (scaduti/imminenti — DEVE usarne almeno 1):\n" . implode("\n", array_map(fn($n) => "→ $n", $mandatoryItems)); + if (!empty($recommendedItems)) $mustUseText .= "\n\n🔶 CONSIGLIATI (aperti/in scadenza):\n" . implode("\n", array_map(fn($n) => "· $n", $recommendedItems)); + + // Meal labels + $mealLabels = ['colazione'=>'colazione (mattina)','pranzo'=>'pranzo (mezzogiorno)','cena'=>'cena (sera)','dolce'=>'dolce/dessert','succo'=>'succo di frutta/bevanda']; + $mealLabel = $mealLabels[$mealType] ?? $mealType; + $mealLabelSimple = ['colazione'=>'colazione','pranzo'=>'pranzo','cena'=>'cena','dolce'=>'dolce','succo'=>'succo']; + + $subTypeLabels = [ + 'dolce' => ['torta'=>'Torta (soffice, da forno: torta di mele, ciambellone, plumcake, angel cake, ecc.)','crema'=>'Crema o Budino (crema pasticcera, panna cotta, mousse, tiramisù, budino, semifreddo)','crumble'=>'Crumble o Crostata (base croccante: crumble di frutta, crostata, sbriciolata)','biscotti'=>'Biscotti o Pasticcini (biscotti, cookies, muffin, cupcake, pasticcini)','frutta'=>'Dolce alla Frutta (macedonia creativa, frutta caramellata, sorbetto, frullato dolce)'], + 'succo' => ['dolce'=>'Succo Dolce e Fruttato (mix di frutta dolce: pesca, mela, pera, fragola, banana)','energizzante'=>'Succo Energizzante (con zenzero, curcuma, barbabietola, carota, mela verde)','detox'=>'Succo Detox / Verde (cetriolo, sedano, spinaci, mela verde, limone)','rinfrescante'=>'Succo Rinfrescante (anguria, menta, lime, cetriolo, acqua di cocco)','vitaminico'=>'Succo Vitaminico / Agrumi (arancia, pompelmo, limone, kiwi, mandarino)'], + ]; + $subTypeText = ''; + if (!empty($subType) && isset($subTypeLabels[$mealType][$subType])) { + $subHint = $subTypeLabels[$mealType][$subType]; + $mealLabel .= " — tipo: $subHint"; + $subTypeText = "\n\n🎨 SOTTO-TIPO: {$subHint}. La ricetta DEVE essere di questo tipo."; + } + + $extraRules = []; + $optionLabels = ['veloce'=>'VELOCE: max 15-20 min totali.','pocafame'=>'POCA FAME: porzione leggera, snack o insalata.','scadenze'=>'PRIORITÀ SCADENZE: usa per primi i prodotti in scadenza.','salutare'=>'SALUTARE: ingredienti integrali, verdure, pochi grassi.','opened'=>'PRIORITÀ APERTI: usa per primi i prodotti [APERTO].','zerowaste'=>'ZERO SPRECHI: usa il più possibile ingredienti in scadenza.']; + foreach ($options as $opt) { if (isset($optionLabels[$opt])) $extraRules[] = $optionLabels[$opt]; } + $extraRulesText = !empty($extraRules) ? "\n\nPREFERENZE DELL'UTENTE:\n" . implode("\n", $extraRules) : ''; + $appliancesText = !empty($appliances) ? "\n\nELETTRODOMESTICI: " . implode(', ', $appliances) . " (+ fornelli e forno). Usa SOLO questi." : ''; + $dietaryText = !empty($dietaryRestrictions) ? "\n\nRESTRIZIONI ALIMENTARI:\n{$dietaryRestrictions}\nRispetta SEMPRE queste restrizioni." : ''; + + $mealPlanTypeLabels = ['pasta'=>'Pasta (primo piatto a base di pasta)','riso'=>'Riso (risotto, insalata di riso, riso saltato, ecc.)','carne'=>'Carne (secondo piatto a base di carne)','pesce'=>'Pesce (secondo piatto a base di pesce o frutti di mare)','legumi'=>'Legumi (zuppa, insalata, hummus, pasta e fagioli, ecc.)','uova'=>'Uova (frittata, uova strapazzate, quiche, ecc.)','formaggio'=>'Formaggio (fonduta, gnocchi al formaggio, torta salata, ecc.)','pizza'=>'Pizza o focaccia (impastata in casa o usi ingredienti simili)','affettati'=>'Affettati (tagliere misto, piadina, panino, ecc.)','verdure'=>'Verdure (piatto principale a base di verdure, contorno abbondante)','zuppa'=>'Zuppa o minestra (zuppe, vellutate, minestrone)','insalata'=>'Insalata (insalata mista, insalata di riso o pasta, poke)','pane'=>'Pane / Sandwich (toast, tramezzino, bruschette)','dolce'=>'Dolce o dessert','libero'=>'']; + $typeKeywords = ['pesce'=>['tonno','salmone','merluzzo','branzino','orata','sardine','acciughe','alici','gamberi','cozze','vongole','polpo','calamari','seppia','sgombro','trota','baccalà','dentice','spigola','pesce'],'carne'=>['pollo','manzo','maiale','vitello','agnello','tacchino','salsiccia','hamburger','bistecca','cotoletta','pancetta','speck','carne','arrosto','filetto','lonza','braciola'],'pasta'=>['pasta','spaghetti','penne','rigatoni','fusilli','tagliatelle','lasagne','farfalle','orecchiette','bucatini','linguine','maccheroni','gnocchi','pennette','bavette'],'riso'=>['riso','basmati','arborio','carnaroli','parboiled','riso integrale'],'legumi'=>['fagioli','ceci','lenticchie','piselli','fave','lupini','soia','legumi','borlotti','cannellini','azuki'],'uova'=>['uova','uovo'],'formaggio'=>['formaggio','parmigiano','mozzarella','ricotta','pecorino','grana','gorgonzola','scamorza','fontina','emmental','asiago','provola','provolone','taleggio','stracchino'],'pizza'=>['farina','lievito','pizza','focaccia'],'affettati'=>['prosciutto','salame','bresaola','mortadella','speck','coppa','affettati','wurstel','würstel','piadina','pancetta cotta'],'verdure'=>['zucchine','zucchina','melanzane','peperoni','spinaci','cavolfiore','broccoli','carote','zucca','bietole','cavolo','carciofi','asparagi','lattuga','rucola','radicchio','cicoria','finocchio','cipolla','porri','verdure'],'zuppa'=>['brodo','zuppa','minestra','minestrone','vellutata','orzo','farro','fagioli','ceci','lenticchie'],'insalata'=>['insalata','lattuga','rucola','spinaci','radicchio','misticanza','valeriana','songino'],'pane'=>['pane','pancarrè','baguette','toast','tramezzino','crackers','grissini','ciabatta','rosetta'],'dolce'=>['cioccolato','cacao','zucchero','miele','marmellata','nutella','creme caramel','savoiardi','biscotti','pan di spagna','panna']]; + + $mealPlanText = ''; + $mealPlanRule = ''; + if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') { + $hint = $mealPlanTypeLabels[$mealPlanType]; + $matchingItems = []; + if (isset($typeKeywords[$mealPlanType])) { + foreach ($items as $item) { + $nameLower = mb_strtolower($item['name'] . ' ' . ($item['brand'] ?? '')); + foreach ($typeKeywords[$mealPlanType] as $kw) { + if (mb_strpos($nameLower, $kw) !== false) { + $entry = "→ {$item['name']}" . ($item['brand'] ? " ({$item['brand']})" : '') . ": {$item['quantity']} {$item['unit']}"; + if (!empty($item['expiry_date'])) { $dl = intval($item['days_left']); $entry .= $dl < 0 ? " [SCADUTO]" : " [scade tra $dl giorni]"; } + $matchingItems[] = $entry; + break; + } + } + } + $matchingItems = array_unique($matchingItems); + } + $matchingBlock = !empty($matchingItems) + ? "Ingredienti disponibili compatibili (usa almeno uno come BASE):\n" . implode("\n", $matchingItems) + : "Nessun ingrediente perfettamente corrispondente — usa la cosa più affine disponibile e segnalalo in nutrition_note."; + $mealPlanText = "\n\n🎯 TIPO OBBLIGATORIO: {$hint}\n{$matchingBlock}"; + $mealPlanRule = "0. La ricetta DEVE essere: {$hint}. Usa gli ingredienti compatibili come base.\n "; + } + + $varietyText = ''; + $today = date('Y-m-d'); $weekAgo = date('Y-m-d', strtotime('-7 days')); + $weekStmt = $db->prepare("SELECT date, meal, recipe_json FROM recipes WHERE date >= ? ORDER BY date DESC"); + $weekStmt->execute([$weekAgo]); + $weekDbRecipes = $weekStmt->fetchAll(); + $todayTitles = []; $weekTitles = []; + foreach ($weekDbRecipes as $tr) { + $rj = json_decode($tr['recipe_json'], true); + if (!empty($rj['title'])) { $weekTitles[] = $rj['title']; if ($tr['date'] === $today) $todayTitles[] = $rj['title']; } + } + if (!empty($todayRecipes)) $todayTitles = array_unique(array_merge($todayTitles, $todayRecipes)); + if (!empty($todayTitles)) { + $todayList = implode(', ', array_map(fn($t) => '"' . $t . '"', $todayTitles)); + $varietyText .= "\n\nGIÀ FATTO OGGI: {$todayList} — proponi qualcosa di DIVERSO."; + } + $weekOnly = array_diff($weekTitles, $todayTitles); + if (!empty($weekOnly)) { + $weekList = implode(', ', array_map(fn($t) => '"' . $t . '"', array_values($weekOnly))); + $varietyText .= "\n\nULTIMI 7GG: {$weekList} — varia."; + } + + $regenText = ''; + if ($variation > 0) { + $regenText = "\n\n🔁 RIGENERA #{$variation}: proponi qualcosa di COMPLETAMENTE DIVERSO (altro stile, altro ingrediente principale, altra tecnica)."; + if (!empty($rejectedIngredients)) { + $rejList = implode(', ', array_map(fn($n) => '"' . $n . '"', $rejectedIngredients)); + $regenText .= " Evita come ingrediente principale: {$rejList}."; + } + } + + // ── AGENTE PASSO 2: Selezione concetto (locale, nessuna chiamata AI) ──────── + // Determina il concetto della ricetta in base agli ingredienti disponibili + // e ai parametri selezionati — senza consumare quote Gemini. + $send('status', ['step' => 2, 'message' => "🧠 Valuto gli ingredienti disponibili..."]); + + // Raccoglie i nomi degli ingredienti di maggiore priorità + $conceptIngredients = []; + foreach ([1, 2, 3, 5, 6] as $g) { + foreach (array_slice($priorityGroups[$g] ?? [], 0, 4) as $line) { + $name = trim(explode(':', ltrim($line, '- '))[0]); + // Rimuove emoji e flag di urgenza + $name = trim(preg_replace('/\s*[\x{26A0}\x{1F534}\x{1F7E0}].*$/u', '', $name)); + $name = trim(preg_replace('/\s*\[.*\]/', '', $name)); + if ($name) $conceptIngredients[] = $name; + } + if (count($conceptIngredients) >= 6) break; + } + + // Costruisce un messaggio di stato informativo basato su ciò che verrà cucinato + $conceptMsg = '👨‍🍳 Preparo la ricetta...'; + if (!empty($mealPlanType) && isset($mealPlanTypeLabels[$mealPlanType]) && $mealPlanTypeLabels[$mealPlanType] !== '') { + // Tipo di pasto dal piano settimanale — mostra la categoria + $shortLabel = explode(' (', $mealPlanTypeLabels[$mealPlanType])[0]; + $conceptMsg = "🎯 Piatto a base di {$shortLabel}"; + // Aggiungi l'ingrediente principale se disponibile + if (!empty($matchingItems)) { + $firstMatch = ltrim(reset($matchingItems), '→ '); + $fName = trim(explode(':', $firstMatch)[0]); + if ($fName) $conceptMsg .= " ({$fName})"; + } + } elseif (!empty($conceptIngredients)) { + // Mostra i primi 2 ingredienti più urgenti + $shown = array_slice($conceptIngredients, 0, 2); + $conceptMsg = "🥘 Ricetta con " . implode(' e ', array_map('mb_strtolower', $shown)); + if ($variation > 0) $conceptMsg .= " — variante #{$variation}"; + } elseif (!empty($subType) && !empty($subTypeLabels[$mealType][$subType])) { + $conceptMsg = "🎨 " . explode(' (', $subTypeLabels[$mealType][$subType])[0]; + } + $send('status', ['step' => 2, 'message' => $conceptMsg]); + + // ── AGENTE PASSO 3: Generazione ricetta (A+C: retry SSE-aware + fallback modello) ── + $conceptHint = ''; + $send('status', ['step' => 3, 'message' => '✍️ Creo la ricetta completa...']); + + $prompt = << min(1.4, 0.7 + $variation * 0.25), + 'maxOutputTokens' => 4096, + 'thinkingConfig' => ['thinkingBudget' => 0], // disabilita thinking: libera token per output + ]; + $payload = ['contents' => [['parts' => [['text' => $prompt]]]], 'generationConfig' => $genConfig]; + + // A: retry SSE-aware con feedback live; C: fallback automatico su quota separata + // Ordine: 2.5-flash (quota separata e spesso più disponibile) → 2.0-flash + $models = [ + 'gemini-2.5-flash', // primario: quota TPM separata da 2.0 + 'gemini-2.0-flash', // fallback + ]; + + $result = null; + $httpCode = 0; + + foreach ($models as $modelIdx => $model) { + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + $maxRetries = 3; // 1 chiamata + max 2 retry con attesa + + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + $retryAfterHeader = null; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 60, + CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$retryAfterHeader) { + if (stripos($header, 'retry-after:') === 0) { + $val = intval(trim(substr($header, strlen('retry-after:')))); + if ($val > 0) $retryAfterHeader = $val; + } + return strlen($header); + }, + ]); + + $body = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($body === false) $body = ''; + + $result = [ + 'http_code' => $httpCode, + 'body' => $body, + 'data' => $body ? json_decode($body, true) : null, + ]; + + // Successo o errore non-retry → esci dal loop retry + if ($httpCode === 200) break 2; + if ($httpCode !== 429 && $httpCode !== 503) break; + if ($attempt >= $maxRetries) break; + + // Calcola attesa: usa Retry-After se presente, altrimenti 30s (poi cambieremo modello) + $waitSec = $retryAfterHeader ?? 30; + if ($body) { + $errData = json_decode($body, true); + foreach (($errData['error']['details'] ?? []) as $detail) { + if (!empty($detail['retryDelay'])) { + $parsed = intval(preg_replace('/\D/', '', $detail['retryDelay'])); + if ($parsed > 0) { $waitSec = min($parsed + 2, 60); break; } + } + } + } + $waitSec = min($waitSec, 60); // cap a 60s + + // A: feedback live con countdown + $modelName = str_replace('gemini-', 'Gemini ', $model); + $send('status', ['step' => 3, 'message' => "⏳ Quota TPM esaurita ({$modelName}), attendo {$waitSec}s... (tentativo {$attempt}/{$maxRetries})"]); + sleep($waitSec); + $send('status', ['step' => 3, 'message' => '✍️ Riprovo la generazione...']); + } + + // C: se primario esaurito dopo tutti i retry, cambia modello immediatamente + if ($httpCode === 429 && $modelIdx === 0) { + $fallbackName = str_replace('gemini-', 'Gemini ', $models[1]); + $send('status', ['step' => 3, 'message' => "🔄 Cambio modello → {$fallbackName}..."]); + continue; + } + break; + } + + if ($httpCode !== 200) { + $errDetail = $result['data']['error']['message'] ?? substr($result['body'], 0, 300); + $send('error', ['error' => 'Errore API Gemini', 'http_code' => $httpCode, 'detail' => $errDetail]); + return; + } + + $text = $result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $text = preg_replace('/^```json\s*/i', '', $text); + $text = preg_replace('/\s*```$/i', '', $text); + $text = trim($text); + $recipe = json_decode($text, true); + + if (!$recipe || empty($recipe['title'])) { + $send('error', ['error' => 'Impossibile generare la ricetta', 'raw' => $text]); + return; + } + + // ── Post-process: fuzzy-match ingredients → inventory (same as generateRecipe) ── + if (!empty($recipe['ingredients'])) { + $itemsLookup = []; + foreach ($items as $item) { + $itemsLookup[] = [ + 'item' => $item, + 'lower' => mb_strtolower(trim($item['name']), 'UTF-8'), + 'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')), + 'cat' => mb_strtolower($item['category'] ?? '', 'UTF-8'), + ]; + } + $aliases = ['uovo'=>['uova','uovo','egg'],'uova'=>['uovo','uova','egg'],'latte'=>['latte','milk'],'formaggio'=>['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'],'pasta'=>['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'],'pomodoro'=>['pomodoro','pomodori','tomato','passata','pelati','polpa'],'cipolla'=>['cipolla','cipolle','onion'],'aglio'=>['aglio','garlic'],'burro'=>['burro','butter'],'panna'=>['panna','cream','crema'],'zucchero'=>['zucchero','sugar'],'farina'=>['farina','flour'],'olio'=>['olio','oil'],'patata'=>['patata','patate','potato'],'carota'=>['carota','carote','carrot'],'sedano'=>['sedano','celery'],'prezzemolo'=>['prezzemolo','parsley'],'basilico'=>['basilico','basil']]; + + foreach ($recipe['ingredients'] as &$ing) { + if (empty($ing['from_pantry'])) continue; + $ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8'); + $ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower); + $bestMatch = null; + $bestScore = 0; + foreach ($itemsLookup as $entry) { + $itemNameLower = $entry['lower']; + $itemWords = $entry['words']; + $score = 0; + if ($ingNameLower === $itemNameLower) { + $score = 100; + } elseif (mb_strpos($itemNameLower, $ingNameLower) !== false) { + $score = 80; + } elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) { + $score = 70; + } else { + $expandedIngWords = $ingWords; + foreach ($ingWords as $w) { + foreach ($aliases as $key => $group) { + if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) + $expandedIngWords = array_merge($expandedIngWords, $group); + } + } + $expandedIngWords = array_unique($expandedIngWords); + $common = 0; + foreach ($expandedIngWords as $ew) { + foreach ($itemWords as $iw) { + $minLen = min(mb_strlen($ew), mb_strlen($iw)); + if ($minLen >= 3) { + $prefixLen = 0; + for ($c = 0; $c < $minLen; $c++) { + if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++; else break; + } + if ($prefixLen >= min(4, $minLen)) { $common++; break; } + } + if ($ew === $iw) { $common++; break; } + } + } + if ($common > 0) { + $score = ($common / max(count($ingWords), 1)) * 65; + if (count($ingWords) > 0) { + foreach ($itemWords as $iw) { + if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) { $score += 10; break; } + } + } + } + } + if ($score > $bestScore) { $bestScore = $score; $bestMatch = $entry['item']; } + } + if ($bestMatch && $bestScore > 30) { + $ing['product_id'] = (int)$bestMatch['product_id']; + $ing['location'] = $bestMatch['location']; + $ing['inventory_unit'] = $bestMatch['unit']; + $ing['inventory_qty'] = (float)$bestMatch['quantity']; + $ing['default_quantity'] = (float)($bestMatch['default_quantity'] ?? 0); + $ing['package_unit'] = $bestMatch['package_unit'] ?? ''; + $ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit']; + $ing['vacuum_sealed'] = !empty($bestMatch['vacuum_sealed']) ? 1 : 0; + if (!empty($bestMatch['brand'])) $ing['brand'] = $bestMatch['brand']; + if (!empty($bestMatch['expiry_date'])) $ing['expiry_date'] = $bestMatch['expiry_date']; + $qtyNum = (float)($ing['qty_number'] ?? 0); + $invUnit = $bestMatch['unit'] ?? 'pz'; + $invQty = (float)$bestMatch['quantity']; + if ($qtyNum > 0) { + $recipeQty = $ing['qty'] ?? ''; + $recipeUnit = ''; $recipeVal = 0; + if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $recipeQty, $qm)) { + $recipeVal = (float)str_replace(',', '.', $qm[1]); + $ru = strtolower($qm[2]); + if (strpos($ru, 'g') === 0) $recipeUnit = 'g'; + elseif ($ru === 'kg') { $recipeUnit = 'g'; $recipeVal *= 1000; } + elseif ($ru === 'ml') $recipeUnit = 'ml'; + elseif ($ru === 'cl') { $recipeUnit = 'ml'; $recipeVal *= 10; } + elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $recipeUnit = 'ml'; $recipeVal *= 1000; } + elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $recipeUnit = 'pz'; + elseif (strpos($ru, 'conf') === 0) $recipeUnit = 'conf'; + } + if ($recipeUnit && $recipeUnit !== $invUnit) { + if ($recipeUnit === 'g' && $invUnit === 'kg') $qtyNum = $recipeVal / 1000; + elseif ($recipeUnit === 'g' && $invUnit === 'g') $qtyNum = $recipeVal; + elseif ($recipeUnit === 'ml' && $invUnit === 'l') $qtyNum = $recipeVal / 1000; + elseif ($recipeUnit === 'ml' && $invUnit === 'ml') $qtyNum = $recipeVal; + elseif ($invUnit === 'pz' || $invUnit === 'conf') { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + if ($defQty > 0) { $qtyNum = $recipeVal / $defQty; $qtyNum = max(0.25, round($qtyNum * 4) / 4); } + else $qtyNum = max(1, round($recipeVal / 100)); + } + } + if ($qtyNum > $invQty) $qtyNum = $invQty; + if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) $qtyNum = $recipeVal; + $ing['qty_number'] = round($qtyNum, 3); + } + } + } + unset($ing); + } + + $send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']); + $send('recipe', ['recipe' => $recipe]); +} + // ===== GEMINI AI PRODUCT IDENTIFICATION ===== function geminiIdentifyProduct(): void { $apiKey = env('GEMINI_API_KEY'); @@ -2544,8 +3348,6 @@ Rispondi SOLO con un JSON valido (senza markdown, senza backtick): } PROMPT; - $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; - $payload = [ 'contents' => [ [ @@ -2566,25 +3368,16 @@ PROMPT; ] ]; - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => json_encode($payload), - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); + $result = callGeminiWithFallback($apiKey, $payload, 30); + $httpCode = $result['http_code']; - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($response === false || $httpCode !== 200) { - echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode]); + if ($httpCode !== 200) { + $errMsg = $result['data']['error']['message'] ?? 'Errore API Gemini'; + echo json_encode(['success' => false, 'error' => $errMsg, 'http_code' => $httpCode]); return; } - $data = json_decode($response, true); + $data = $result['data']; $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; $text = preg_replace('/^```json\\s*/i', '', $text); @@ -2824,6 +3617,392 @@ function italianToBring(string $italianName): string { return $italianName; } +/** + * Auto-compute a generic shopping/Bring! name for a product. + * + * Priority: + * 1. Curated keyword map — groups cured meats, etc. that the catalog doesn't unify + * 2. Bring! catalog back-translation — "Latte di Montagna" → "Milch" → "Latte" + * 3. First significant token capitalized + * + * The returned string is always a valid Bring! catalog name where possible, + * so that italianToBring(computeShoppingName($n)) resolves to a catalog key. + */ +/** + * Ask Gemini to classify a product name into a short Italian shopping category word. + * Results are cached in a local JSON file to avoid repeated API calls. + * Returns null on failure so the caller can fall back gracefully. + */ +function _geminiClassifyProduct(string $name, string $brand, string $category): ?string { + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) return null; + + // Load/save classification cache + $cacheFile = __DIR__ . '/../data/shopping_name_cache.json'; + $cache = []; + if (file_exists($cacheFile)) { + $raw = @file_get_contents($cacheFile); + if ($raw) $cache = json_decode($raw, true) ?: []; + } + $cacheKey = md5(mb_strtolower($name . '|' . $brand)); + if (isset($cache[$cacheKey])) return $cache[$cacheKey]; + + // Build catalog list so Gemini picks an existing Bring! entry when possible + $catalog = bringCatalog(); + $catalogList = implode(', ', array_slice(array_values($catalog['de2it']), 0, 200)); + + $prompt = << [['parts' => [['text' => $prompt]]]], + 'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 16], + ]; + + $result = callGeminiWithFallback($apiKey, $payload, 15); + if ($result['http_code'] !== 200 || !isset($result['data']['candidates'][0])) return null; + + $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); + // Sanitize: keep only letters and spaces, max 30 chars, capitalize first letter + $text = preg_replace('/[^\p{L}\s]/u', '', $text); + $text = trim(preg_replace('/\s+/', ' ', $text)); + if (mb_strlen($text) < 2 || mb_strlen($text) > 30) return null; + $text = mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1); + + // Persist to cache + $cache[$cacheKey] = $text; + @file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + + return $text; +} + +function computeShoppingName(string $name, string $category = '', string $brand = ''): string { + $lower = mb_strtolower(trim($name)); + $stop = ['di','del','della','dei','degli','delle','da','in','con','per','su', + 'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo', + 'parzialmente','scremato','uht','bio','light','freschi','fresca','fresco']; + $tokens = array_values(array_filter( + preg_split('/\s+/', preg_replace('/[^\p{L}\s]/u', ' ', $lower)), + fn($w) => mb_strlen($w) > 2 && !in_array($w, $stop) + )); + + // 0. Compound-phrase map — checked against the FULL lowercase name (stop words included) + // so multi-word product types are classified BEFORE single-token lookup. + // This prevents "Pane grattugiato" → "Pane", "Panna da cucina" → "Panna", etc. + $phraseMap = [ + // Breadcrumbs (MUST come before generic "pane") + 'pangrattato' => 'Pangrattato', + 'pan grattato' => 'Pangrattato', + 'pane grattato' => 'Pangrattato', + 'pane grattugiato' => 'Pangrattato', + 'pan grattugiato' => 'Pangrattato', + // Cooking cream (MUST come before generic "panna") + 'panna da cucina' => 'Panna da cucina', + 'panna cucina' => 'Panna da cucina', + 'panna chef' => 'Panna da cucina', + 'panna acida' => 'Panna acida', + // Plant-based milks (MUST come before generic "latte") + 'latte condensato' => 'Latte condensato', + 'latte evaporato' => 'Latte condensato', + 'latte di soia' => 'Latte di soia', + 'latte soia' => 'Latte di soia', + 'latte vegetale' => 'Latte vegetale', + 'latte di mandorla' => 'Latte di mandorla', + 'latte mandorla' => 'Latte di mandorla', + 'latte di avena' => 'Latte di avena', + 'latte avena' => 'Latte di avena', + 'latte di riso' => 'Latte di riso', + 'latte riso' => 'Latte di riso', + 'latte di cocco' => 'Latte di cocco', + 'latte cocco' => 'Latte di cocco', + // Baked bakery — different from bread + 'fette biscottate' => 'Fette biscottate', + 'pan di spagna' => 'Pan di Spagna', + // Specific vinegars + 'aceto balsamico' => 'Aceto balsamico', + 'glassa balsamico' => 'Aceto balsamico', + 'glassa balsamic' => 'Aceto balsamico', + // Cold cuts — specific cuts + 'prosciutto cotto' => 'Prosciutto cotto', + // Flour subtypes (MUST come before generic "farina") + 'farina di riso' => 'Farina di riso', + 'farina riso' => 'Farina di riso', + 'farina di mais' => 'Farina di mais', + 'farina mais' => 'Farina di mais', + 'farina integrale' => 'Farina integrale', + 'farina 00' => 'Farina', + // Roux / sugar subtypes + 'zucchero di canna' => 'Zucchero di canna', + 'zucchero canna' => 'Zucchero di canna', + 'zucchero velo' => 'Zucchero a velo', + 'zucchero a velo' => 'Zucchero a velo', + // Fresh pasta + 'pasta fresca' => 'Pasta fresca', + // Broth / stock + 'brodo vegetale' => 'Brodo', + 'brodo pollo' => 'Brodo', + 'brodo manzo' => 'Brodo', + // Mixed vegetable purée / passato (MUST come before generic carote/patate) + 'passato di verdure' => 'Verdure', + 'passato di patate' => 'Verdure', + // Water + 'acqua frizzante' => 'Acqua', + 'acqua gassata' => 'Acqua', + 'acqua minerale' => 'Acqua', + // Aroma / flavouring + 'aroma vaniglia' => 'Ingredienti Spezie', + 'aroma mandorla' => 'Ingredienti Spezie', + 'aroma limone' => 'Ingredienti Spezie', + 'aroma rum' => 'Ingredienti Spezie', + 'aroma arancia' => 'Ingredienti Spezie', + ]; + foreach ($phraseMap as $phrase => $canonical) { + if (mb_strpos($lower, $phrase) !== false) { + return $canonical; + } + } + + // 1. Curated keyword → canonical group name. + // Extended list covers the most common Italian pantry items and avoids Gemini calls. + $keywordMap = [ + // Cold cuts / affettati + 'mortadella' => 'Affettato', + 'nduja' => 'Affettato', + 'salame' => 'Affettato', + 'salami' => 'Affettato', + 'coppa' => 'Affettato', + 'capicola' => 'Affettato', + 'speck' => 'Affettato', + 'schinkenspeck' => 'Affettato', + 'schinken' => 'Affettato', + 'prosciutto' => 'Affettato', + // Items with their own Bring! entry + 'bresaola' => 'Bresaola', + 'pancetta' => 'Pancetta', + 'salsiccia' => 'Salsiccia', + 'wurstel' => 'Wurstel', + // Bread & bakery + 'pane' => 'Pane', + 'bauletto' => 'Pane', + 'pancarrè' => 'Pane', + 'pancare' => 'Pane', + 'toast' => 'Pane', + 'focaccia' => 'Pane', + 'ciabatta' => 'Pane', + 'baguette' => 'Pane', + 'grissini' => 'Grissini', + 'crackers' => 'Cracker', + 'cracker' => 'Cracker', + 'taralli' => 'Taralli', + 'tarallini' => 'Taralli', + 'piadina' => 'Piadina', + 'piadelle' => 'Piadina', + 'biscotto' => 'Biscotti', + 'biscotti' => 'Biscotti', + // Breadcrumbs single-token safety net (phrase map has priority, but just in case) + 'grattugiato' => 'Pangrattato', + 'grattato' => 'Pangrattato', + 'pangrattato' => 'Pangrattato', + 'biscottate' => 'Fette biscottate', + // Leavening agents + 'lievito' => 'Lievito', + // Flavourings / aromas (single-token fallback; phrases handled above) + 'aroma' => 'Ingredienti Spezie', + // Dairy + 'latte' => 'Latte', + 'yogurt' => 'Yogurt', + 'yaourt' => 'Yogurt', + 'yougurt' => 'Yogurt', + 'burro' => 'Burro', + 'panna' => 'Panna', + 'mozzarella' => 'Mozzarella', + 'formaggio' => 'Formaggio', + 'ricotta' => 'Ricotta', + 'ricottina' => 'Ricotta', + 'casatella' => 'Formaggio', + 'philadelphia' => 'Formaggio cremoso', + // "Bel Paese" — known Italian cheese brand + 'bel' => 'Formaggio', + // Pasta + 'pasta' => 'Pasta', + 'spaghetti' => 'Pasta', + 'penne' => 'Pasta', + 'rigatoni' => 'Pasta', + 'fusilli' => 'Pasta', + 'orecchiette' => 'Pasta', + 'tortiglioni' => 'Pasta', + 'linguine' => 'Pasta', + 'sedani' => 'Pasta', + 'lasagne' => 'Pasta', + 'tortellini' => 'Pasta', + 'gnocchi' => 'Gnocchi', + // Rice + 'riso' => 'Riso', + // Eggs + 'uova' => 'Uova', + 'uovo' => 'Uova', + // Fruit & veg + 'mela' => 'Mele', + 'mele' => 'Mele', + 'pera' => 'Pere', + 'arancia' => 'Arance', + 'arance' => 'Arance', + 'limone' => 'Limone', + 'banana' => 'Banane', + 'banane' => 'Banane', + 'kiwi' => 'Kiwi', + 'avocado' => 'Avocado', + 'pomodoro' => 'Pomodori', + 'pomodori' => 'Pomodori', + 'pomodorini' => 'Pomodorini', + 'carota' => 'Carote', + 'carote' => 'Carote', + 'cipolla' => 'Cipolla', + 'cipolle' => 'Cipolla', + 'aglio' => 'Aglio', + 'zucchina' => 'Zucchine', + 'zucchine' => 'Zucchine', + 'spinaci' => 'Spinaci', + 'lattuga' => 'Insalata', + 'melone' => 'Melone', + 'finocchio' => 'Finocchio', + // Condiments & pantry + 'olio' => 'Olio', + 'aceto' => 'Aceto', + 'sale' => 'Sale', + 'zucchero' => 'Zucchero', + 'farina' => 'Farina', + 'lievito' => 'Lievito', + 'miele' => 'Miele', + 'marmellata' => 'Marmellata', + 'confettura' => 'Marmellata', + 'maionese' => 'Maionese', + 'senape' => 'Senape', + 'ketchup' => 'Ketchup', + // Canned / preserved + 'passata' => 'Passata', + 'polpa' => 'Polpa di pomodoro', + 'pelati' => 'Pelati', + 'tonno' => 'Tonno', + 'sardine' => 'Sardine', + 'ceci' => 'Ceci', + 'lenticchie' => 'Lenticchie', + 'fagioli' => 'Fagioli', + 'piselli' => 'Piselli', + 'mais' => 'Mais', + // Frozen + 'surgelato' => 'Surgelati', + 'surgelati' => 'Surgelati', + // Drinks + 'vino' => 'Vino', + 'birra' => 'Birra', + 'succo' => 'Succo', + // Cereals & snacks + 'muesli' => 'Muesli', + 'cereali' => 'Cereali', + // Frozen & desserts (before coffee/tea tokens to avoid "gelato caffè → Caffè") + 'gelato' => 'Gelato', + 'semifreddo' => 'Gelato', + // Beverages (coffee, tea, herbal) + 'camomilla' => 'Camomilla', + 'camomille' => 'Camomilla', + 'tisana' => 'Tè', + // Cat food / pet + 'gatto' => 'Cibo per gatti', + 'cane' => 'Cibo per cani', + // Known product/brand single tokens → category override + 'risofrolle' => 'Cracker', + 'zuppalatte' => 'Biscotti', + 'kaffee' => 'Caffè', + 'ovomaltine' => 'Bevande', + 'ciobar' => 'Cioccolata calda', + 'apfelsaft' => 'Succo', + 'kartoffelpüree'=> 'Purè', + 'purée' => 'Purè', + 'pure' => 'Purè', + 'inchusa' => 'Birra', + 'ichnusa' => 'Birra', + 'vesoletto' => 'Vino', + 'trebbiano' => 'Vino', + 'sangiovese' => 'Vino', + 'barbera' => 'Vino', + 'chianti' => 'Vino', + 'soave' => 'Vino', + 'prosecco' => 'Vino', + 'frizzante' => 'Acqua', + 'semolino' => 'Semolino', + 'bicarbonato' => 'Bicarbonato', + 'sambuca' => 'Liquore', + 'limoncello' => 'Liquore', + 'grappa' => 'Liquore', + 'dado' => 'Brodo', + 'zuccheri' => 'Zucchero', + 'zucchero' => 'Zucchero', + // Foreign-language tokens + 'jus' => 'Succo', + 'zumo' => 'Succo', + 'arome' => 'Aroma', + 'caffe' => 'Caffè', + 'caffè' => 'Caffè', + ]; + + foreach ($tokens as $token) { + if (isset($keywordMap[$token])) { + return $keywordMap[$token]; + } + } + + // 2. Bring! catalog back-translation: "Latte di Montagna" → "Milch" → "Latte" + $bringKey = italianToBring($name); + if ($bringKey !== $name) { + $italian = bringToItalian($bringKey); + if ($italian && mb_strtolower($italian) !== $lower) { + return $italian; + } + } + + // 3. Gemini AI classification — called when: + // - The name has 2+ tokens (e.g. "Gran bauletto rustico"), + // - OR the single token doesn't look like a clean Italian product word + // (contains non-Italian chars, uppercase mix, brand-style length, etc.), + // - OR category/brand context is available to help Gemini disambiguate. + // Single-token ultra-common words (5+ lowercase Italian chars) that already look + // like valid category names are skipped (unlikely to need AI). + $firstToken = $tokens[0] ?? ''; + $isCleanItalianToken = count($tokens) === 1 + && mb_strlen($firstToken) >= 5 + && mb_strtolower($firstToken) === $firstToken // all lowercase → already in stop-word-free form + && preg_match('/^[a-z]+$/', $firstToken); // only ASCII lowercase (no accents = usually Italian noun) + $hasCategoryHint = $category !== '' || $brand !== ''; + $needsAI = !$isCleanItalianToken || ($hasCategoryHint && count($tokens) >= 2); + if ($needsAI) { + $aiResult = _geminiClassifyProduct($name, $brand, $category); + if ($aiResult !== null) return $aiResult; + } + + // 4. Fallback: capitalize the first meaningful token. + if (!empty($tokens)) { + return mb_strtoupper(mb_substr($firstToken, 0, 1)) . mb_substr($firstToken, 1); + } + return ucfirst($name); +} + function bringGetList(): void { $auth = bringAuth(); if (!$auth) { @@ -2879,17 +4058,30 @@ function bringGetList(): void { 'purchase' => $purchase, 'recently' => $recently, ], JSON_UNESCAPED_UNICODE); + + // ── Background auto-migration ───────────────────────────────────────── + // After sending the response, silently migrate any item that still uses + // the specific product name instead of the generic shopping_name. + // This runs at most once every 10 minutes (flag file throttle) to avoid + // hammering the Bring! API on every page load. + $flagFile = __DIR__ . '/../data/bring_migrate_ts.json'; + $doMigrate = true; + if (file_exists($flagFile)) { + $ts = (int)(json_decode(file_get_contents($flagFile), true)['ts'] ?? 0); + if ((time() - $ts) < 600) $doMigrate = false; + } + if ($doMigrate) { + file_put_contents($flagFile, json_encode(['ts' => time()])); + // Use a global PDO instance if available, otherwise open a new connection + global $db; + if ($db instanceof PDO) { + bringMigrateNamesInternal($db, $data['purchase'] ?? [], $listUUID); + } + } } function bringAddItems(): void { $auth = bringAuth(); - if (!$auth) { - echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); - return; - } - - $input = json_decode(file_get_contents('php://input'), true); - $items = $input['items'] ?? []; $listUUID = $input['listUUID'] ?? $auth['bringListUUID']; if (empty($listUUID)) { @@ -3028,13 +4220,102 @@ function bringCleanSpecs(): void { } /** - * Serve smart shopping from cache (written by cron), falling back to live computation. - * Cache is valid for up to 10 minutes; if stale or missing, compute on the fly. - */ -/** - * Invalidate the smart shopping cache so the next request recomputes live. - * Call after any inventory_add or inventory_use that changes stock meaningfully. + * Core migration logic: iterate $purchaseItems and replace specific product + * names with generic shopping_name in the Bring! list identified by $listUUID. + * Returns ['migrated'=>int, 'skipped'=>int, 'errors'=>int]. */ +function bringMigrateNamesInternal(PDO $db, array $purchaseItems, string $listUUID): array { + // Build lookup: product name (lowercase) → [shopping_name, brand] + $products = $db->query("SELECT name, brand, shopping_name FROM products WHERE shopping_name IS NOT NULL AND shopping_name != ''")->fetchAll(); + $lookup = []; + foreach ($products as $p) { + $lookup[mb_strtolower($p['name'])] = ['shopping_name' => $p['shopping_name'], 'brand' => $p['brand'] ?? '']; + } + + $migrated = 0; + $skipped = 0; + $errors = 0; + + foreach ($purchaseItems as $item) { + $rawName = $item['name'] ?? ''; + $itName = bringToItalian($rawName); + $key = mb_strtolower($itName); + $spec = $item['specification'] ?? ''; + + if (!isset($lookup[$key])) { $skipped++; continue; } + + $shoppingName = $lookup[$key]['shopping_name']; + $brand = $lookup[$key]['brand']; + + // Resolve to the correct Bring! catalog key (German) + $bringKey = italianToBring($shoppingName); + + // Already using the correct catalog key or the shopping name → nothing to do + if (mb_strtolower($rawName) === mb_strtolower($bringKey)) { $skipped++; continue; } + if (mb_strtolower($rawName) === mb_strtolower($shoppingName)) { $skipped++; continue; } + if (mb_strtolower($itName) === mb_strtolower($shoppingName)) { $skipped++; continue; } + + // Build spec: "Specific Name · Brand" + $newSpec = $itName . ($brand ? " · {$brand}" : ''); + if ($spec !== '' && $spec !== $newSpec && stripos($spec, $itName) === false) { + $newSpec = $itName . ($brand ? " · {$brand}" : '') . ' — ' . $spec; + } + + // Check if the correct catalog key is already in the list + $alreadyAdded = false; + foreach ($purchaseItems as $existing) { + if (strcasecmp($existing['name'] ?? '', $bringKey) === 0) { + $alreadyAdded = true; + break; + } + } + + // Remove old item using the correct API (PUT with remove param) + bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", + http_build_query(['uuid' => $listUUID, 'remove' => $rawName])); + + // Add with the correct German catalog key (unless already present) + if (!$alreadyAdded) { + $addBody = http_build_query([ + 'uuid' => $listUUID, + 'purchase' => $bringKey, + 'specification' => $newSpec, + ]); + $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $addBody); + if ($result !== false) { $migrated++; } else { $errors++; } + } else { + $migrated++; // old item removed, correct generic already present + } + } + + return ['migrated' => $migrated, 'skipped' => $skipped, 'errors' => $errors]; +} + +function bringMigrateNames(PDO $db): void { + $auth = bringAuth(); + if (!$auth) { + echo json_encode(['success' => false, 'error' => 'Credenziali Bring! non configurate']); + return; + } + $listUUID = $auth['bringListUUID']; + if (empty($listUUID)) { + echo json_encode(['success' => false, 'error' => 'Lista non trovata']); + return; + } + $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); + if (!$data || !isset($data['purchase'])) { + echo json_encode(['success' => false, 'error' => 'Errore nel recupero della lista']); + return; + } + + $result = bringMigrateNamesInternal($db, $data['purchase'], $listUUID); + + // Reset throttle so next bring_list load re-checks + @unlink(__DIR__ . '/../data/bring_migrate_ts.json'); + + echo json_encode(array_merge(['success' => true], $result)); +} + function invalidateSmartShoppingCache(): void { $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; if (file_exists($cacheFile)) { @@ -3083,7 +4364,13 @@ function smartShoppingCached(PDO $db): void { * product "Muesli Frutta Secca" (which has "frutta" as a secondary token, not the first). * Mirrors JS _matchBringToSmart / _syncOnBringFlags logic. */ -function _productOnBring(string $productName, array $bringItems): bool { +function _productOnBring(string $productName, array $bringItems, string $shoppingName = ''): bool { + // Check by shopping_name first (covers catalog-matched generic names like "Latte", "Affettato") + if ($shoppingName !== '') { + if (isset($bringItems[mb_strtolower($shoppingName)])) return true; + $snKey = italianToBring($shoppingName); + if (isset($bringItems[mb_strtolower($snKey)])) return true; + } // Exact key match (both German raw and Italian translated keys are stored) if (isset($bringItems[mb_strtolower($productName)])) return true; static $stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per','su', @@ -3121,7 +4408,8 @@ function smartShopping(PDO $db): void { // 1. Get all products with their inventory and transaction history $products = $db->query(" - SELECT p.id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit + SELECT p.id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, + p.shopping_name FROM products p ORDER BY p.name ")->fetchAll(); @@ -3417,8 +4705,11 @@ function smartShopping(PDO $db): void { if ($useCount >= 8) $score += 15; elseif ($useCount >= 5) $score += 10; - // Is already on Bring? (fuzzy token match — mirrors JS _findSimilarItem logic) - $onBring = _productOnBring($p['name'], $bringItems); + // Compute generic shopping name for this product + $shoppingName = $p['shopping_name'] ?: computeShoppingName($p['name'], $p['category'], $p['brand']); + + // Is already on Bring? check both product name and generic shopping name + $onBring = _productOnBring($p['name'], $bringItems, $shoppingName); // "Just restocked" suppression: if bought in the last 3 days AND stock is above 50% // of reference qty, skip non-expiry urgency flags. The product doesn't need rebuying yet. @@ -3429,6 +4720,7 @@ function smartShopping(PDO $db): void { $items[] = [ 'product_id' => $pid, 'name' => $p['name'], + 'shopping_name' => $shoppingName, 'brand' => $p['brand'] ?: '', 'category' => $p['category'] ?: '', 'unit' => $unit, @@ -3450,9 +4742,43 @@ function smartShopping(PDO $db): void { 'score' => $score, 'on_bring' => $onBring, 'locations' => $inv ? $inv['locations'] : '', + 'variants' => [], ]; } + // Group items by shopping_name: keep the most urgent representative per group, + // collect the rest as variants so the UI can show "Affettato (Mortadella, Speck, Nduja)". + $grouped = []; + foreach ($items as $item) { + $sn = $item['shopping_name']; + if (!isset($grouped[$sn])) { + $grouped[$sn] = $item; + } else { + // Merge: keep the higher-score item as the representative + if ($item['score'] > $grouped[$sn]['score']) { + $demoted = [ + 'product_id' => $grouped[$sn]['product_id'], + 'name' => $grouped[$sn]['name'], + 'brand' => $grouped[$sn]['brand'], + 'urgency' => $grouped[$sn]['urgency'], + ]; + $variants = array_merge([$demoted], $grouped[$sn]['variants']); + $grouped[$sn] = $item; + $grouped[$sn]['variants'] = $variants; + } else { + $grouped[$sn]['variants'][] = [ + 'product_id' => $item['product_id'], + 'name' => $item['name'], + 'brand' => $item['brand'], + 'urgency' => $item['urgency'], + ]; + } + // on_bring is true if ANY variant in the group is already on Bring! + if ($item['on_bring']) $grouped[$sn]['on_bring'] = true; + } + } + $items = array_values($grouped); + // Sort by score descending (most urgent first) usort($items, fn($a, $b) => $b['score'] - $a['score']); @@ -3460,221 +4786,80 @@ function smartShopping(PDO $db): void { } function bringSuggestItems(PDO $db): void { - $apiKey = env('GEMINI_API_KEY'); - - if (empty($apiKey)) { - echo json_encode(['success' => false, 'error' => 'API Key Gemini non configurata']); - return; + // Offline: derive suggestions from smart shopping cache (no AI needed) + + // 1. Load smart shopping data from cache or compute fresh + $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; + $smartItems = null; + if (file_exists($cacheFile)) { + $raw = file_get_contents($cacheFile); + if ($raw) { + $cached = json_decode($raw, true); + if ($cached && isset($cached['items'])) { + $smartItems = $cached['items']; + } + } } - - // Get current Bring! list - $auth = bringAuth(); - $bringItems = []; + if ($smartItems === null) { + ob_start(); + smartShopping($db); + $raw = ob_get_clean(); + $data = json_decode($raw, true); + $smartItems = $data['items'] ?? []; + } + + // 2. Get Bring! listUUID for response $listUUID = ''; + $auth = bringAuth(); if ($auth) { - $listUUID = $auth['bringListUUID']; - if (empty($listUUID)) { - $lists = bringRequest('GET', "https://api.getbring.com/rest/v2/bringusers/{$auth['uuid']}/lists"); - if ($lists && isset($lists['lists'][0]['listUuid'])) { - $listUUID = $lists['lists'][0]['listUuid']; - } - } - if ($listUUID) { - $data = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}"); - if ($data && isset($data['purchase'])) { - foreach ($data['purchase'] as $item) { - $rawName = $item['name'] ?? ''; - $bringItems[] = bringToItalian($rawName); - } - } - } + $listUUID = $auth['bringListUUID'] ?? ''; } - - // Get inventory - $stmt = $db->query(" - SELECT p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date, - CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left - FROM inventory i - JOIN products p ON p.id = i.product_id - WHERE i.quantity > 0 - ORDER BY p.category, p.name - "); - $inventory = $stmt->fetchAll(PDO::FETCH_ASSOC); - - // Build detailed context with expiry info - $invLines = []; - $expiringItems = []; - $expiredItems = []; - $categories = []; - foreach ($inventory as $item) { - $cat = $item['category'] ?: 'altro'; - $categories[$cat] = ($categories[$cat] ?? 0) + 1; - $line = "- {$item['name']}"; - if ($item['brand']) $line .= " ({$item['brand']})"; - $line .= ": {$item['quantity']} {$item['unit']} in {$item['location']}"; - if ($item['expiry_date']) { - $dl = intval($item['days_left']); - if ($dl < 0) { - $line .= " [⚠️ SCADUTO da " . abs($dl) . " giorni]"; - $expiredItems[] = $item['name']; - } elseif ($dl <= 2) { - $line .= " [🔴 SCADE TRA {$dl} GIORNI - USARE SUBITO]"; - $expiringItems[] = $item['name'] . " (tra {$dl}g)"; - } elseif ($dl <= 7) { - $line .= " [🟡 scade tra {$dl} giorni]"; - $expiringItems[] = $item['name'] . " (tra {$dl}g)"; - } elseif ($dl <= 14) { - $line .= " [scade tra {$dl} giorni]"; - } - } - $invLines[] = $line; - } - $inventoryText = empty($invLines) ? 'La dispensa è COMPLETAMENTE VUOTA.' : implode("\n", $invLines); - - $expiryContext = ''; - if (!empty($expiredItems)) { - $expiryContext .= "\n\nPRODOTTI SCADUTI da sostituire: " . implode(', ', $expiredItems); - } - if (!empty($expiringItems)) { - $expiryContext .= "\n\nPRODOTTI IN SCADENZA (priorità per sostituzione): " . implode(', ', $expiringItems); - } - - $bringText = empty($bringItems) - ? 'La lista della spesa Bring! è attualmente VUOTA.' - : "PRODOTTI GIÀ NELLA LISTA DELLA SPESA BRING! (NON suggerire nessuno di questi, sono già stati aggiunti):\n- " . implode("\n- ", $bringItems); - - // Current month for seasonal suggestions - $mese = strftime('%B') ?: date('F'); - $mesi_it = ['January'=>'Gennaio','February'=>'Febbraio','March'=>'Marzo','April'=>'Aprile','May'=>'Maggio','June'=>'Giugno','July'=>'Luglio','August'=>'Agosto','September'=>'Settembre','October'=>'Ottobre','November'=>'Novembre','December'=>'Dicembre']; - $meseIt = $mesi_it[date('F')] ?? date('F'); - $anno = date('Y'); - - // Get catalog Italian names for AI to use - $catalog = bringCatalog(); - $catalogNames = array_values($catalog['de2it']); - // Filter only food-related items (exclude categories and non-food) - $catalogNames = array_filter($catalogNames, function($n) { - $skip = ['Fai da te', 'Giardino', 'Atrezzi', 'Annaffiatoio', 'Rasaerba', 'Sementi', 'Propangas', 'Vernice', 'Pennello', 'Viti', 'Chiodi', 'Barbecue', 'Ombrellone', 'Terriccio', 'Concime', 'Articoli propri', 'Usati di recente']; - foreach ($skip as $s) { if (str_contains($n, $s)) return false; } - return mb_strlen($n) > 1; - }); - $catalogList = implode(', ', array_slice($catalogNames, 0, 200)); - - $prompt = << [ - ['parts' => [['text' => $prompt]]] - ], - 'generationConfig' => [ - 'temperature' => 0.8, - 'maxOutputTokens' => 2048, - ] + // 3. Convert smart shopping items → suggestions (alta/media priority only, skip on_bring) + $suggestions = []; + $seasonalTips = [ + 1 => 'Gennaio: arance, mandarini, kiwi, carciofi e verze sono di stagione.', + 2 => 'Febbraio: radicchio, finocchi, pere e agrumi da non perdere.', + 3 => 'Marzo: arrivano gli asparagi! Ottimo anche con piselli freschi e spinaci.', + 4 => 'Aprile: stagione di asparagi, carciofi, fave e fragole.', + 5 => 'Maggio: zucchine, fragole, ciliegie — ottimo mese per frutta e verdura fresca.', + 6 => 'Giugno: albicocche, pesche, pomodori freschi, melanzane — estate in arrivo.', + 7 => 'Luglio: cocomero, pesche, melanzane e pomodori sono al loro meglio.', + 8 => 'Agosto: prugne, fichi, peperoni e basilico fresco di stagione.', + 9 => 'Settembre: uva, fichi, funghi porcini, melograno e more.', + 10 => 'Ottobre: melograni, castagne, funghi, mele e pere autunnali.', + 11 => 'Novembre: cachi, melograni, cavoli, broccoli e radicchio tardivo.', + 12 => 'Dicembre: arance, mandarini, cachi, verze e cavolfiori.', ]; - - $ctx = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => "Content-Type: application/json\r\n", - 'content' => json_encode($payload), - 'timeout' => 30, - ] - ]); - - $response = @file_get_contents($url, false, $ctx); - if ($response === false) { - echo json_encode(['success' => false, 'error' => 'Errore di connessione a Gemini']); - return; + $seasonalTip = $seasonalTips[(int)date('n')] ?? ''; + + foreach ($smartItems as $item) { + if ($item['on_bring'] ?? false) continue; // already on shopping list + + $urgency = $item['urgency'] ?? 'low'; + if ($urgency === 'low') continue; // not urgent enough to suggest + + $priority = ($urgency === 'critical' || $urgency === 'high') ? 'alta' : 'media'; + $reasons = $item['reasons'] ?? []; + $reason = !empty($reasons) ? implode(', ', $reasons) : 'Scorte basse'; + + $suggestions[] = [ + 'name' => $item['name'], + 'specification' => '', + 'reason' => $reason, + 'category' => $item['category'] ?: 'altro', + 'priority' => $priority, + ]; + + if (count($suggestions) >= 12) break; } - - $data = json_decode($response, true); - $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; - - // Clean markdown artifacts - $text = preg_replace('/^```json\s*/i', '', $text); - $text = preg_replace('/```\s*$/', '', $text); - $text = trim($text); - - $suggestions = json_decode($text, true); - if (!$suggestions || !isset($suggestions['suggestions'])) { - echo json_encode(['success' => false, 'error' => 'Risposta AI non valida', 'raw' => $text]); - return; - } - - // Post-filter: remove any suggestions that match Bring! list items (safety net) - $bringLower = array_map('mb_strtolower', $bringItems); - $filtered = array_values(array_filter($suggestions['suggestions'], function($s) use ($bringLower) { - $sName = mb_strtolower($s['name'] ?? ''); - foreach ($bringLower as $b) { - // Check exact match or if one contains the other - if ($sName === $b || str_contains($sName, $b) || str_contains($b, $sName)) { - return false; - } - } - return true; - })); - + echo json_encode([ - 'success' => true, - 'suggestions' => $filtered, - 'seasonal_tip' => $suggestions['seasonal_tip'] ?? '', - 'listUUID' => $listUUID, - ]); + 'success' => true, + 'suggestions' => $suggestions, + 'seasonal_tip' => $seasonalTip, + 'listUUID' => $listUUID, + ], JSON_UNESCAPED_UNICODE); } // ===== DUPLICLICK (GRUPPO POLI) ===== @@ -3942,73 +5127,116 @@ function dupliclickExtractSpecKeywords(string $spec): string { } /** - * Use Gemini AI to pick the best product from search results + * Pick the best product from search results using offline text-scoring (no AI needed). + * Returns null when nothing matches well enough (triggers refined search with spec keywords). */ function aiSelectBestProduct(string $itemName, string $spec, array $products, string $customPrompt = ''): ?array { - $apiKey = env('GEMINI_API_KEY'); - if (empty($apiKey)) return null; + if (empty($products)) return null; + if (count($products) === 1) return $products[0]; - $defaultPrompt = "Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare (con eventuale descrizione tra parentesi) e una lista di prodotti trovati nel catalogo del supermercato. + $stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per','su', + 'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli', + 'allo','gr','kg','ml','lt','cl','pz','conf','pack']; -Regole di selezione: -- Scegli il prodotto che corrisponde ESATTAMENTE a quello richiesto (stessa categoria merceologica) -- La DESCRIZIONE tra parentesi è FONDAMENTALE: se l'utente cerca \"Pancetta (a cubetti)\", DEVI trovare pancetta A CUBETTI, non pancetta generica -- Se la descrizione include un tipo specifico (\"a cubetti\", \"a fette\", \"biologico\", \"cotto\", \"a pasta dura\"), il prodotto DEVE contenere quella caratteristica nel nome -- Preferisci prodotti freschi/sfusi rispetto a trasformati (es. \"Arance\" = arance frutta, NON aranciata bevanda) -- Se ci sono più varianti valide, scegli quella con il miglior rapporto qualità/prezzo -- Preferisci formati standard per una famiglia -- NON scegliere mai un prodotto di categoria diversa (bevanda vs frutta, surgelato vs fresco, condimento vs ortaggio, pasta ripiena vs formaggio, ecc.) -- \"Finocchio\" = ortaggio fresco, NON semi di finocchio o tisana -- \"Arance\" = frutta fresca, NON aranciata o succo -- \"Formaggio\" = formaggio intero/pezzo, NON prodotti che contengono formaggio come ingrediente (ravioli, sfogliavelo, ecc.) -- \"Detergente intimo\" = detergente per igiene intima, NON detersivo generico -- Rispondi -1 se NESSUN prodotto corrisponde ragionevolmente alla richiesta + $tokenize = function(string $s) use ($stop): array { + $clean = mb_strtolower(preg_replace('/[^\p{L}0-9\s]/u', ' ', $s), 'UTF-8'); + return array_values(array_filter( + preg_split('/\s+/', trim($clean)), + fn($t) => mb_strlen($t, 'UTF-8') > 2 && !in_array($t, $stop) + )); + }; -Rispondi SOLO con il numero (indice 0-based) del prodotto migliore, oppure -1 se nessun prodotto è appropriato."; + $queryTokens = $tokenize($itemName); + $specTokens = $tokenize($spec); - $prompt = !empty($customPrompt) ? $customPrompt : $defaultPrompt; + if (empty($queryTokens)) return $products[0]; - // Build product list - $productList = ''; - foreach ($products as $i => $p) { - $productList .= "[$i] \"{$p['name']}\" - {$p['brand']} - €" . number_format($p['price'], 2) . " - {$p['packageDescr']}\n"; - } + // Variant conflict pairs: if spec says X, penalise products containing opposite + $variantConflicts = [ + 'cubetti' => ['fette','affettata','intera','arrotolata'], + 'fette' => ['cubetti','dadini'], + 'cotto' => ['crudo','stagionato'], + 'crudo' => ['cotto'], + 'intero' => ['macinato','tritato','cubetti','fette'], + 'macinato' => ['intero'], + 'biologico' => [], + ]; - $fullPrompt = "{$prompt}\n\nProdotto cercato: \"{$itemName}\"" . ($spec ? " ({$spec})" : '') . "\n\nProdotti trovati:\n{$productList}\nRispondi SOLO con il numero (es. 0, 1, 2... oppure -1):"; + // Category mismatch: if query implies a category, penalise products from the wrong one + $categoryGuards = [ + ['query' => ['frutta','mele','pere','pesche','fragole','uva','arance','limoni','banane','kiwi'], + 'exclude' => ['succo','succhi','nettare','sciroppo','aranciata','bevanda','bibita']], + ['query' => ['verdura','spinaci','zucchine','carote','finocchio','sedano','broccoli'], + 'exclude' => ['surgelat','succo']], + ['query' => ['formaggio','mozzarella','parmigiano','ricotta','pecorino'], + 'exclude' => ['ravioli','tortellini','cannelloni','lasagne','pizza']], + ['query' => ['pasta','spaghetti','penne','fusilli','rigatoni','tagliatelle'], + 'exclude' => ['insalata','minestra','zuppa','brodo']], + ]; - $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; - $payload = json_encode([ - 'contents' => [['parts' => [['text' => $fullPrompt]]]], - 'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 16], - ]); + $scores = []; + foreach ($products as $idx => $product) { + $productName = $product['name'] ?? ''; + $productBrand = $product['brand'] ?? ''; + $productTokens = $tokenize($productName . ' ' . $productBrand); + $nameLower = mb_strtolower($productName, 'UTF-8'); + $score = 0; - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $payload, - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 15, - ]); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($response === false || $httpCode !== 200) return null; - - $data = json_decode($response, true); - $text = trim($data['candidates'][0]['content']['parts'][0]['text'] ?? ''); - if (preg_match('/-?\d+/', $text, $m)) { - $idx = (int)$m[0]; - if ($idx >= 0 && $idx < count($products)) { - return $products[$idx]; - } elseif ($idx === -1) { - return null; // AI says nothing matches + // --- Token overlap: query vs product --- + foreach ($queryTokens as $qt) { + foreach ($productTokens as $pt) { + if ($qt === $pt) { $score += 6; break; } + if (str_contains($pt, $qt) || str_contains($qt, $pt)) { $score += 2; break; } + } } + + // --- Spec tokens get extra weight (user specified variant) --- + foreach ($specTokens as $st) { + foreach ($productTokens as $pt) { + if ($st === $pt) { $score += 8; break; } + if (str_contains($pt, $st) || str_contains($st, $pt)) { $score += 3; break; } + } + } + + // --- First-token anchor bonus --- + if (!empty($queryTokens) && !empty($productTokens) && $queryTokens[0] === $productTokens[0]) { + $score += 10; + } + + // --- Category mismatch penalty --- + foreach ($categoryGuards as $guard) { + if (!empty(array_intersect($queryTokens, $guard['query']))) { + foreach ($guard['exclude'] as $exc) { + if (str_contains($nameLower, $exc)) { $score -= 50; break; } + } + } + } + + // --- Variant conflict penalty --- + foreach ($specTokens as $st) { + if (isset($variantConflicts[$st])) { + foreach ($variantConflicts[$st] as $conflict) { + if (str_contains($nameLower, $conflict)) { $score -= 20; } + } + } + } + + $scores[$idx] = $score; } - return null; // Could not parse, caller will use first result + arsort($scores); + reset($scores); + $topIdx = key($scores); + $topScore = current($scores); + next($scores); + $secondScore = current($scores) ?: 0; + + // Return null (triggers spec-refined search) only when spec is given and no product + // matches well, so the caller can retry with more specific keywords. + if (!empty($spec) && $topScore < 4) return null; + + // Otherwise return the best scoring result (fallback to first if score is 0) + return $products[$topIdx]; } function formatDupliclickProduct(array $p): array { diff --git a/assets/css/style.css b/assets/css/style.css index 9efb0c8..60f57dd 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -1963,6 +1963,14 @@ body { line-height: 1.3; } +.smart-item-specific { + font-size: 0.73rem; + color: var(--text-muted); + margin-top: 1px; + line-height: 1.3; + font-style: italic; +} + .smart-brand { font-weight: 400; color: var(--text-muted); @@ -3115,6 +3123,8 @@ body { margin-top: 16px; color: var(--text-muted); font-weight: 600; + transition: opacity 0.25s ease; + min-height: 1.4em; } .recipe-result { @@ -4525,6 +4535,12 @@ body { background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); border-color: #8b5cf6; } +.alert-banner.banner-anomaly { + background: linear-gradient(135deg, #fff7ed 0%, #fed7aa 100%); + border-color: #ea580c; +} +.banner-anomaly .alert-banner-title { color: #9a3412; } +.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; } .alert-banner-inner { display: flex; align-items: flex-start; @@ -5663,3 +5679,36 @@ body { background: #fee2e2; color: #dc2626; } +.alert-banner.banner-expired-danger { + background: linear-gradient(135deg, #fca5a5 0%, #f87171 100%); + border-color: #b91c1c; + border-width: 2px; +} +.banner-expired-danger .alert-banner-title { + color: #7f1d1d; +} +.banner-safety-tip { + display: inline-block; + font-size: 0.82em; + margin-top: 1px; +} +.banner-safety-danger { + color: #b91c1c; + font-weight: 600; +} +.banner-safety-warning { + color: #92400e; +} +.banner-safety-ok { + color: #166534; +} +.btn-banner-throw-primary { + background: #dc2626; + color: #fff; + font-weight: 600; +} +.btn-banner-use-danger { + background: #f3f4f6; + color: #9ca3af; + font-size: 0.8em; +} diff --git a/assets/js/app.js b/assets/js/app.js index bf49a5c..fcb6f4c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -321,7 +321,7 @@ function _scaleAutoFillUse(msg) { if (scaleAlreadyMl) { const density = _scaleDensityForProduct(currentProduct); val = Math.round(grams * density); - if (density !== 1.00) hintExtra = ` (densità ${density} g/ml)`; + if (density !== 1.00) hintExtra = ' ' + t('scale.density_hint', { density }); } else { val = Math.round(grams); } @@ -331,7 +331,7 @@ function _scaleAutoFillUse(msg) { } else { const density = _scaleDensityForProduct(currentProduct); val = Math.round(grams / density); - if (density !== 1.00) hintExtra = ` (densità ${density} g/ml)`; + if (density !== 1.00) hintExtra = ' ' + t('scale.density_hint', { density }); } } @@ -407,7 +407,7 @@ function _scaleAutoFillRecipeUse(msg) { if (scaleAlreadyMl) { const density = _scaleDensityForProduct(currentProduct); val = Math.round(grams * density); - if (density !== 1.00) hintExtra = ` (densità ${density} g/ml)`; + if (density !== 1.00) hintExtra = ' ' + t('scale.density_hint', { density }); } else { val = Math.round(grams); } @@ -417,7 +417,7 @@ function _scaleAutoFillRecipeUse(msg) { } else { const density = _scaleDensityForProduct(currentProduct); val = Math.round(grams / density); - if (density !== 1.00) hintExtra = ` (densità ${density} g/ml)`; + if (density !== 1.00) hintExtra = ' ' + t('scale.density_hint', { density }); } } @@ -434,21 +434,21 @@ function _scaleAutoFillRecipeUse(msg) { livVal.textContent = `${msg.value} ${msg.unit || 'kg'}`; } } - if (livStatus) livStatus.textContent = msg.stable ? '✓ Stabile' : '…'; + if (livStatus) livStatus.textContent = msg.stable ? t('scale.stable') : '…'; // Update live hint in modal with the raw scale reading always const hint = document.getElementById('ruse-scale-hint'); if (hint) { hint.textContent = `⚖️ Bilancia: ${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`; if (unit === 'ml' && srcUnit !== 'ml') { - hint.textContent += ' (verrà convertito in ml)'; + hint.textContent += ' ' + t('scale.ml_hint'); } hint.style.display = ''; } if (val < 10) { _cancelScaleStabilityWait(); // stop bar only; keep sentinel - if (livLabel) livLabel.textContent = 'Peso troppo basso — attendi…'; + if (livLabel) livLabel.textContent = t('scale.weight_too_low'); return; } @@ -461,7 +461,7 @@ function _scaleAutoFillRecipeUse(msg) { _scaleStabilityVal = val; _scaleUserDismissed = false; _cancelScaleTimersOnly(); - if (livLabel) livLabel.textContent = 'Peso rilevato — attendi 10s di stabilità…'; + if (livLabel) livLabel.textContent = t('scale.weight_detected'); // Hide confirm bar when new value arrives const confirmWrap = document.getElementById('ruse-scale-confirm-wrap'); if (confirmWrap) confirmWrap.style.display = 'none'; @@ -472,7 +472,7 @@ function _scaleAutoFillRecipeUse(msg) { hint.textContent = `⚖️ Peso bilancia: ${val} ${unit}${hintExtra}`; hint.style.display = ''; } - if (livLabel) livLabel.textContent = `✅ ${val} ${unit} — conferma automatica tra 5s (tocca per annullare)`; + if (livLabel) livLabel.textContent = t('scale.auto_confirm', { val, unit }); if (livVal) livVal.style.color = '#22c55e'; const confirmWrap2 = document.getElementById('ruse-scale-confirm-wrap'); if (confirmWrap2) { confirmWrap2.style.display = ''; } @@ -486,11 +486,11 @@ function _scaleAutoFillRecipeUse(msg) { }); } else if (!_scaleUserDismissed && !_scaleStabilityTimer && !_scaleAutoConfirmTimer) { _cancelScaleTimersOnly(); - if (livLabel) livLabel.textContent = 'Peso rilevato — attendi 10s di stabilità…'; + if (livLabel) livLabel.textContent = t('scale.weight_detected'); _startScaleStabilityWait(() => { const inp = document.getElementById('ruse-quantity'); if (inp) inp.value = val; - if (livLabel) livLabel.textContent = `✅ ${val} ${unit} — conferma automatica tra 5s (tocca per annullare)`; + if (livLabel) livLabel.textContent = t('scale.auto_confirm', { val, unit }); if (livVal) livVal.style.color = '#22c55e'; const confirmWrap3 = document.getElementById('ruse-scale-confirm-wrap'); if (confirmWrap3) confirmWrap3.style.display = ''; @@ -532,7 +532,7 @@ function _cancelScaleTimersOnly() { if (livVal) livVal.style.color = ''; const livLabel = document.getElementById('ruse-scale-live-label'); if (livLabel && livLabel.textContent.startsWith('✅')) { - livLabel.textContent = 'Annullato — rimetti l\'ingrediente sulla bilancia per riprendere'; + livLabel.textContent = t('scale.cancelled_replace'); } document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true); } @@ -1111,12 +1111,12 @@ function getExpiredSafety(item, daysExpired) { const effectiveDays = daysExpired - bonusDays; if (effectiveDays <= 0) { - return { level: 'ok', icon: '✅', label: 'OK', tip: `In freezer: ancora sicuro (~${bonusDays - daysExpired}g di margine)` }; + return { level: 'ok', icon: '✅', label: t('status.ok'), tip: t('status.tip_freezer_ok').replace('{n}', bonusDays - daysExpired) }; } if (effectiveDays <= 30) { - return { level: 'warning', icon: '👀', label: 'Controlla', tip: `In freezer da molto, potrebbe aver perso qualità. Consumare presto` }; + return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_freezer_check') }; } - return { level: 'danger', icon: '🗑️', label: 'Buttare', tip: 'In freezer da troppo tempo, rischio di bruciatura da gelo e degrado' }; + return { level: 'danger', icon: '🗑️', label: t('status.discard'), tip: t('status.tip_freezer_danger') }; } // === FRIGO e DISPENSA === @@ -1125,29 +1125,29 @@ function getExpiredSafety(item, daysExpired) { if (highRisk.includes(cat)) { if (inFrigo && daysExpired <= 2) { - return { level: 'warning', icon: '👀', label: 'Controlla', tip: 'Scaduto da poco, controlla odore e aspetto prima di consumare' }; + return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_highRisk_check') }; } - return { level: 'danger', icon: '🗑️', label: 'Buttare', tip: 'Prodotto deperibile scaduto: da buttare per sicurezza' }; + return { level: 'danger', icon: '🗑️', label: t('status.discard'), tip: t('status.tip_highRisk_danger') }; } if (medRisk.includes(cat)) { if (daysExpired <= 7) { - return { level: 'warning', icon: '👀', label: 'Controlla', tip: 'Controlla aspetto e odore prima di consumare' }; + return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_medRisk_check1') }; } if (daysExpired <= 30) { - return { level: 'warning', icon: '👀', label: 'Controlla', tip: 'Scaduto da un po\', verificare bene prima dell\'uso' }; + return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_medRisk_check2') }; } - return { level: 'danger', icon: '🗑️', label: 'Buttare', tip: 'Troppo tempo dalla scadenza, meglio buttare' }; + return { level: 'danger', icon: '🗑️', label: t('status.discard'), tip: t('status.tip_medRisk_danger') }; } // LOW RISK - lunga conservazione (pasta, conserve, condimenti, cereali, snack) if (daysExpired <= 30) { - return { level: 'ok', icon: '✅', label: 'OK', tip: 'Prodotto a lunga conservazione, ancora sicuro da consumare' }; + return { level: 'ok', icon: '✅', label: t('status.ok'), tip: t('status.tip_lowRisk_ok') }; } if (daysExpired <= 180) { - return { level: 'warning', icon: '👀', label: 'Controlla', tip: 'Scaduto da oltre un mese, controllare integrità confezione' }; + return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_lowRisk_check') }; } - return { level: 'danger', icon: '🗑️', label: 'Buttare', tip: 'Scaduto da troppo tempo, meglio non rischiare' }; + return { level: 'danger', icon: '🗑️', label: t('status.discard'), tip: t('status.tip_lowRisk_danger') }; } // Nice Italian labels for local categories @@ -1286,10 +1286,10 @@ function estimateExpiryDays(product, location) { } function formatEstimatedExpiry(days) { - if (days <= 7) return `~${days} giorni`; - if (days <= 30) return `~${Math.round(days / 7)} settimane`; - if (days <= 365) return `~${Math.round(days / 30)} mesi`; - return `~${Math.round(days / 365)} anni`; + if (days <= 7) return t('expiry.days_approx').replace('{n}', days); + if (days <= 30) return t('expiry.weeks_approx').replace('{n}', Math.round(days / 7)); + if (days <= 365) return t('expiry.months_approx').replace('{n}', Math.round(days / 30)); + return t('expiry.years_approx').replace('{n}', Math.round(days / 365)); } /** @@ -1769,7 +1769,7 @@ function _injectKioskOverlay() { exitBtn.style.cssText = btnStyle; exitBtn.addEventListener('click', (e) => { e.stopPropagation(); - if (confirm('Uscire dalla modalità kiosk?')) _kioskBridge.exit(); + if (confirm(t('confirm.kiosk_exit'))) _kioskBridge.exit(); }); // Refresh button @@ -2113,11 +2113,11 @@ async function loadDashboard() { expiringList.innerHTML = statsData.expiring_soon.map(item => { const days = daysUntilExpiry(item.expiry_date); let badgeText, badgeClass; - if (days === 0) { badgeText = 'OGGI'; badgeClass = 'today'; } - else if (days === 1) { badgeText = 'Domani'; badgeClass = 'expiring'; } - else if (days <= 7) { badgeText = `${days} giorni`; badgeClass = 'expiring'; } - else if (days <= 30) { badgeText = `${days}g`; badgeClass = 'expiring-soon'; } - else { const m = Math.round(days/30); badgeText = m <= 1 ? `${days}g` : `~${m} mesi`; badgeClass = 'expiring-later'; } + if (days === 0) { badgeText = t('expiry.today'); badgeClass = 'today'; } + else if (days === 1) { badgeText = t('expiry.tomorrow'); badgeClass = 'expiring'; } + else if (days <= 7) { badgeText = t('expiry.days').replace('{days}', days); badgeClass = 'expiring'; } + else if (days <= 30) { badgeText = t('expiry.days_compact').replace('{n}', days); badgeClass = 'expiring-soon'; } + else { const m = Math.round(days/30); badgeText = m <= 1 ? t('expiry.days_compact').replace('{n}', days) : t('expiry.months_approx').replace('{n}', m); badgeClass = 'expiring-later'; } const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); return `
@@ -2143,9 +2143,9 @@ async function loadDashboard() { expiredList.innerHTML = statsData.expired.map(item => { const days = Math.abs(daysUntilExpiry(item.expiry_date)); let daysText; - if (days === 0) daysText = 'Oggi'; - else if (days === 1) daysText = 'Da ieri'; - else daysText = `Da ${days}g`; + if (days === 0) daysText = t('expiry.expired_today'); + else if (days === 1) daysText = t('expiry.expired_yesterday'); + else daysText = t('expiry.expired_days').replace('{days}', days); const safety = getExpiredSafety(item, days); const locIcon = item.location === 'freezer' ? '❄️' : item.location === 'frigo' ? '🧊' : ''; const qtyDisplayExp = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); @@ -2183,8 +2183,8 @@ async function loadDashboard() {
`; document.getElementById('waste-chart-legend').innerHTML = ` - Consumati: ${used30} (${usedPct}%) - Buttati: ${wasted30} (${wastedPct}%) + ${t('dashboard.consumed').replace('{n}', used30).replace('{pct}', usedPct)} + ${t('dashboard.wasted').replace('{n}', wasted30).replace('{pct}', wastedPct)} `; } else { wasteSection.style.display = 'none'; @@ -2225,7 +2225,7 @@ async function loadDashboard() { const wholePackages = Math.floor(qty / pkgSize + 0.001); const remainder = Math.round((qty - wholePackages * pkgSize) * 100) / 100; if (wholePackages > 0 && remainder > 0.01) { - qtyText = `${wholePackages} × ${pkgSize}${unitLabel} + ${Math.round(remainder)}${unitLabel} rimasti`; + qtyText = `${wholePackages} × ${pkgSize}${unitLabel} + ${Math.round(remainder)}${unitLabel} ${t('inventory.qty_remainder_suffix')}`; } else if (remainder > 0.01) { qtyText = `${Math.round(remainder)}${unitLabel} / ${pkgSize}${unitLabel}`; } else { @@ -2241,22 +2241,22 @@ async function loadDashboard() { let expiryClass, expiryText; if (!isEdible) { expiryClass = 'opened-expiry-spoiled'; - expiryText = '⛔ Scaduto!'; + expiryText = t('expiry.badge_expired'); } else if (days > 365) { expiryClass = 'opened-expiry-ok'; - expiryText = '✅ Stabile'; + expiryText = t('expiry.badge_stable'); } else if (days === 0) { expiryClass = 'opened-expiry-today'; - expiryText = '⚠️ Scade oggi!'; + expiryText = t('expiry.badge_today'); } else if (days <= 2) { expiryClass = 'opened-expiry-urgent'; - expiryText = `⏰ Scade fra ${days}gg`; + expiryText = t('expiry.badge_expiring_short').replace('{n}', days); } else if (days <= 5) { expiryClass = 'opened-expiry-soon'; - expiryText = `⏰ Scade fra ${days}gg`; + expiryText = t('expiry.badge_expiring_short').replace('{n}', days); } else { expiryClass = 'opened-expiry-ok'; - expiryText = `✅ Ancora ${days}gg`; + expiryText = t('expiry.badge_ok_still').replace('{n}', days); } const vacuumNote = item.vacuum_sealed ? ' 🔒' : ''; expiryBadge = `${expiryText}${vacuumNote}`; @@ -2274,7 +2274,7 @@ async function loadDashboard() { ${expiryBadge}
`; - }).join('') + (extra > 0 ? `
e altri ${extra} prodotti aperti...
` : ''); + }).join('') + (extra > 0 ? `
${t('dashboard.more_opened').replace('{n}', extra)}
` : ''); } else { openedSection.style.display = 'none'; } @@ -2354,32 +2354,47 @@ async function loadBannerAlerts() { if (!banner) { console.warn('[Banner] #alert-banner not found'); return; } try { - const [invData, predData] = await Promise.all([ + const [invData, predData, anomalyData, finishedData] = await Promise.all([ api('inventory_list'), api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }), + api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }), + api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }), ]); const items = invData.inventory || []; const confirmed = getReviewConfirmed(); // 1. Expired products (highest priority) - derived from inventory + // Also considers opened_at: if item is opened and its opened-shelf-life has passed, it's expired too items.forEach(item => { - if (!item.expiry_date) return; - const days = daysUntilExpiry(item.expiry_date); - if (days >= 0) return; // not expired + if (!item.expiry_date && !item.opened_at) return; if (confirmed['exp_' + item.id]) return; - _bannerQueue.push({ type: 'expired', data: { ...item, days_expired: Math.abs(days) } }); + + let daysExpired = null; + + // Check raw expiry date + if (item.expiry_date) { + const rawDays = daysUntilExpiry(item.expiry_date); + if (rawDays < 0) daysExpired = Math.abs(rawDays); + } + + // Check effective expiry based on opened_at + if (item.opened_at) { + const openDays = estimateOpenedExpiryDays(item, item.location); + const openedTs = new Date(item.opened_at).getTime(); + const effectiveExpiry = new Date(openedTs + openDays * 86400000); + const today = new Date(); today.setHours(0, 0, 0, 0); + const openedDiff = Math.round((effectiveExpiry.getTime() - today.getTime()) / 86400000); + if (openedDiff < 0) { + const openedExpiredDays = Math.abs(openedDiff); + if (daysExpired === null || openedExpiredDays > daysExpired) daysExpired = openedExpiredDays; + } + } + + if (daysExpired === null) return; // not expired by any measure + _bannerQueue.push({ type: 'expired', data: { ...item, days_expired: daysExpired } }); }); - // 2. Products expiring very soon (today, tomorrow, within 3 days) - items.forEach(item => { - if (!item.expiry_date) return; - const days = daysUntilExpiry(item.expiry_date); - if (days < 0 || days > 3) return; - if (confirmed['exps_' + item.id]) return; - _bannerQueue.push({ type: 'expiring', data: { ...item, days_left: days } }); - }); - - // 3. Suspicious quantities + // 2. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner) items.forEach(item => { if (confirmed[item.id]) return; if (isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit)) { @@ -2401,6 +2416,20 @@ async function loadBannerAlerts() { _bannerQueue.push({ type: 'prediction', data: pred }); }); + // 5. Inventory anomalies (qty doesn't match transaction history) + const anomalies = anomalyData.anomalies || []; + anomalies.forEach(an => { + if (confirmed['an_' + an.dismiss_key]) return; + _bannerQueue.push({ type: 'anomaly', data: an }); + }); + + // 6. Finished products: inventory hit 0, waiting for user confirmation + const finished = finishedData.finished || []; + finished.forEach(fin => { + if (confirmed['fin_' + fin.product_id]) return; + _bannerQueue.push({ type: 'finished', data: fin }); + }); + // Sort by priority (highest first) _bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a)); @@ -2425,7 +2454,7 @@ async function loadBannerAlerts() { * * Priority tiers: * 1000+ : expired (longer ago = higher) - * 500-999: expiring today/tomorrow/soon (sooner = higher) + * 500-799: anomalies (data discrepancies) * 200-499: suspicious quantities (low stock > high stock > package) * 100-199: consumption predictions (higher deviation% = higher) */ @@ -2436,11 +2465,6 @@ function _bannerPriority(entry) { // Expired longer = more urgent; base 1000 + days (capped) return 1000 + Math.min(d, 500); } - case 'expiring': { - const d = entry.data.days_left ?? 3; - // Today=999, tomorrow=998, 2d=997, 3d=996 - return 999 - d; - } case 'review': { const w = entry.data.warning || ''; // Low stock is more urgent than too-much @@ -2453,6 +2477,12 @@ function _bannerPriority(entry) { // Higher deviation = more important, capped at 99 return 100 + Math.min(dev, 99); } + case 'anomaly': { + // Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong) + return entry.data.direction === 'missing' ? 260 : 250; + } + case 'finished': + return 600; // product ran out — confirm before removing from DB default: return 0; } @@ -2475,61 +2505,115 @@ function renderBannerItem() { if (entry.type === 'expired') { const item = entry.data; const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); - const daysText = item.days_expired === 0 ? t('dashboard.banner_expired_today') : t('dashboard.banner_expired_days', { days: item.days_expired }); - banner.className = 'alert-banner banner-expired'; + const daysText = item.days_expired === 0 + ? t('expiry.expired_today_long') + : t('expiry.expired_ago_long').replace('{n}', item.days_expired); + const safety = getExpiredSafety(item, item.days_expired); + banner.className = safety.level === 'danger' + ? 'alert-banner banner-expired banner-expired-danger' + : 'alert-banner banner-expired'; iconEl.textContent = '🚫'; - titleEl.textContent = `${t('dashboard.banner_expired_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; - detailEl.textContent = `${daysText} · ${qtyDisplay}`; - let btns = ``; - btns += ``; - btns += ``; + titleEl.textContent = `${item.name}${item.brand ? ' (' + item.brand + ')' : ''} ${t('expiry.expired_suffix')}`; + const baseDetail = t('dashboard.banner_expired_detail').replace('{when}', daysText).replace('{qty}', qtyDisplay); + detailEl.innerHTML = `${baseDetail} `; + let btns = ''; + if (safety.level !== 'danger') { + btns += ``; + } + btns += ``; + btns += ``; + if (safety.level === 'danger') { + btns += ``; + } btns += ``; actionsEl.innerHTML = btns; - } else if (entry.type === 'expiring') { - const item = entry.data; - const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); - let urgencyText; - if (item.days_left === 0) urgencyText = t('dashboard.banner_expiring_today'); - else if (item.days_left === 1) urgencyText = t('dashboard.banner_expiring_tomorrow'); - else urgencyText = t('dashboard.banner_expiring_days', { days: item.days_left }); - banner.className = 'alert-banner banner-expiring'; - iconEl.textContent = '⏰'; - titleEl.textContent = `${t('dashboard.banner_expiring_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; - detailEl.textContent = `${urgencyText} · ${qtyDisplay}`; - let btns = ``; - btns += ``; - btns += ``; - actionsEl.innerHTML = btns; - } else if (entry.type === 'review') { const item = entry.data; const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); + const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit); + const suspQty = isSuspiciousQty(item.quantity, item.unit); + const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz']; banner.className = 'alert-banner'; iconEl.textContent = '⚠️'; - titleEl.textContent = `${t('dashboard.banner_review_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; - detailEl.textContent = `${item.warning} · ${qtyDisplay}`; + let titleText, detailText; + if (suspDq && !suspQty) { + titleText = `${t('dashboard.banner_review_unusual_pkg_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; + detailText = t('dashboard.banner_review_unusual_pkg_detail', { qty: item.default_quantity, unit: item.package_unit }); + } else if (parseFloat(item.quantity) < t_.min) { + titleText = `${t('dashboard.banner_review_low_qty_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; + detailText = t('dashboard.banner_review_low_qty_detail', { qty: qtyDisplay }); + } else { + titleText = `${t('dashboard.banner_review_high_qty_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; + detailText = t('dashboard.banner_review_high_qty_detail', { qty: qtyDisplay }); + } + titleEl.textContent = titleText; + detailEl.textContent = detailText; let btns = ``; btns += ``; if (hasScale) { - btns += ``; + btns += ``; } actionsEl.innerHTML = btns; } else if (entry.type === 'prediction') { const pred = entry.data; + const dir = pred.direction || 'less'; + const dailyRate = parseFloat(pred.daily_rate) || 0; + const daysSince = parseInt(pred.days_since_restock) || 0; banner.className = 'alert-banner banner-prediction'; iconEl.textContent = '📊'; titleEl.textContent = `${t('dashboard.banner_prediction_title')}: ${pred.name}${pred.brand ? ' (' + pred.brand + ')' : ''}`; - const expTxt = t('prediction.expected_qty').replace('{expected}', pred.expected_qty).replace('{unit}', pred.unit); - const actTxt = t('prediction.actual_qty').replace('{actual}', pred.actual_qty).replace('{unit}', pred.unit); - detailEl.innerHTML = `${expTxt} · ${actTxt}
${t('prediction.check_suggestion')}`; - let btns = ``; + let rateText = ''; + if (dailyRate > 0) { + rateText = dailyRate >= 1 + ? t('dashboard.banner_prediction_rate_day', { n: Math.round(dailyRate), unit: pred.unit }) + : t('dashboard.banner_prediction_rate_week', { n: Math.round(dailyRate * 7), unit: pred.unit }); + } + const timeText = daysSince > 0 ? ` — ${t('dashboard.banner_prediction_days_ago', { n: daysSince })}` : ''; + let diffText; + if (dir === 'more') { + diffText = t('dashboard.banner_prediction_more', { expected: pred.expected_qty, unit: pred.unit, time: timeText, actual: pred.actual_qty }); + } else { + diffText = t('dashboard.banner_prediction_less', { expected: pred.expected_qty, unit: pred.unit, time: timeText, actual: pred.actual_qty }); + } + detailEl.innerHTML = rateText ? `${rateText}: ${diffText}` : diffText.charAt(0).toUpperCase() + diffText.slice(1); + let btns = ``; btns += ``; if (hasScale) { - btns += ``; + btns += ``; } actionsEl.innerHTML = btns; + + } else if (entry.type === 'finished') { + const fin = entry.data; + banner.className = 'alert-banner banner-finished'; + iconEl.textContent = '📦'; + 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'); + let btns = ``; + btns += ``; + actionsEl.innerHTML = btns; + + } else if (entry.type === 'anomaly') { + const an = entry.data; + const isPhantom = an.direction === 'phantom'; + banner.className = 'alert-banner banner-anomaly'; + iconEl.textContent = '🔍'; + if (isPhantom) { + titleEl.textContent = `${an.name} — ${t('dashboard.banner_anomaly_phantom_title')}`; + detailEl.innerHTML = t('dashboard.banner_anomaly_phantom_detail', { inv_qty: an.inv_qty, unit: an.unit, expected_qty: an.expected_qty }); + } else { + titleEl.textContent = `${an.name} — ${t('dashboard.banner_anomaly_ghost_title')}`; + detailEl.innerHTML = t('dashboard.banner_anomaly_ghost_detail', { expected_qty: an.expected_qty, unit: an.unit, name: an.name, inv_qty: an.inv_qty }); + } + let btns = ``; + btns += ``; + actionsEl.innerHTML = btns; } if (_bannerQueue.length > 1) { @@ -2574,7 +2658,7 @@ function confirmBannerPrediction() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'prediction') return; setReviewConfirmed('pred_' + entry.data.inventory_id); - showToast(t('toast.quantity_confirmed'), 'success'); + showToast('✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni', 'success'); dismissBannerItem(); } @@ -2585,6 +2669,23 @@ function editBannerPrediction() { editReviewItem(entry.data.inventory_id, entry.data.product_id); } +function editBannerAnomaly() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'anomaly') return; + _bannerEditPending = true; + editReviewItem(entry.data.inventory_id, entry.data.product_id); +} + +function dismissBannerAnomaly() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'anomaly') return; + const key = entry.data.dismiss_key; + setReviewConfirmed('an_' + key); + api('dismiss_anomaly', {}, 'POST', { dismiss_key: key }).catch(() => {}); + showToast('Anomalia ignorata', 'info'); + dismissBannerItem(); +} + function weighBannerItem() { const entry = _bannerQueue[_bannerIndex]; if (!entry) return; @@ -2655,6 +2756,40 @@ function dismissBannerExpiring() { dismissBannerItem(); } +async function confirmBannerFinished() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'finished') return; + const productId = entry.data.product_id; + try { + await api('inventory_confirm_finished', {}, 'POST', { product_id: productId }); + } catch(e) {} + setReviewConfirmed('fin_' + productId); + showToast(t('toast.product_finished_confirmed'), 'success'); + dismissBannerItem(); +} + +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(); + 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); + showToast(t('error.connection'), 'error'); + } +} + // --- Banner swipe navigation --- let _bannerTouchStartX = 0; let _bannerTouchStartY = 0; @@ -2742,10 +2877,10 @@ function renderDashItem(item) { let expiryLabel = ''; if (item.expiry_date) { - if (days < 0) expiryLabel = `⚠️ Scaduto da ${Math.abs(days)}g`; - else if (days === 0) expiryLabel = '⚠️ Scade oggi!'; - else if (days === 1) expiryLabel = '⏰ Scade domani'; - else if (days <= 7) expiryLabel = `⏰ ${days} giorni`; + if (days < 0) expiryLabel = t('expiry.badge_expired_ago').replace('{n}', Math.abs(days)); + else if (days === 0) expiryLabel = t('expiry.badge_today'); + else if (days === 1) expiryLabel = t('expiry.badge_tomorrow_long'); + else if (days <= 7) expiryLabel = t('expiry.badge_days').replace('{n}', days); else expiryLabel = formatDate(item.expiry_date); } @@ -2915,16 +3050,16 @@ function renderInventoryItem(item) { let expiryBadge = ''; if (item.expiry_date) { let expiryText; - if (isExpired) expiryText = `⚠️ Scaduto da ${Math.abs(days)}g`; - else if (days === 0) expiryText = '⚠️ Scade oggi!'; - else if (days === 1) expiryText = '⏰ Domani'; - else if (days <= 7) expiryText = `⏰ ${days} giorni`; + if (isExpired) expiryText = t('expiry.badge_expired_ago').replace('{n}', Math.abs(days)); + else if (days === 0) expiryText = t('expiry.badge_today'); + else if (days === 1) expiryText = t('expiry.badge_tomorrow'); + else if (days <= 7) expiryText = t('expiry.badge_days').replace('{n}', days); else expiryText = formatDate(item.expiry_date); expiryBadge = `${expiryText}`; } - const vacuumBadge = item.vacuum_sealed ? '🫙 Sotto vuoto' : ''; - const openedBadge = item.opened_at ? '📭 Aperto' : ''; + const vacuumBadge = item.vacuum_sealed ? `${t('inventory.vacuum_badge')}` : ''; + const openedBadge = item.opened_at ? `${t('inventory.opened_badge')}` : ''; return `
@@ -2952,7 +3087,7 @@ function renderInventoryItem(item) { function renderInventory(items) { const container = document.getElementById('inventory-list'); if (items.length === 0) { - container.innerHTML = '
📭

Nessun prodotto qui.
Scansiona un prodotto per aggiungerlo!

'; + container.innerHTML = `
📭

${t('inventory.empty_text')}

`; return; } container.innerHTML = renderGroupedByCategory(items, false); @@ -3072,27 +3207,27 @@ function showItemDetail(inventoryId, productId) {
@@ -3157,7 +3292,7 @@ async function quickUse(productId, location) { } async function deleteInventoryItem(id) { - if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) { + if (confirm(t('confirm.remove_item'))) { await api('inventory_delete', {}, 'POST', { id }); closeModal(); showToast(t('toast.product_removed'), 'success'); @@ -3208,7 +3343,7 @@ function editInventoryItem(id) {
- +
@@ -3239,7 +3374,7 @@ function editInventoryItem(id) {
- +
${Object.entries(LOCATIONS).map(([k, v]) => `
- +
- + `; document.getElementById('modal-overlay').style.display = 'flex'; @@ -3319,7 +3454,8 @@ let _scanLogTimer = null; function scanLog(msg) { const el = document.getElementById('scan-debug-log'); if (el) { - const ts = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:1}); + const _scanLocale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT'; + const ts = new Date().toLocaleTimeString(_scanLocale, {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:1}); el.textContent += `[${ts}] ${msg}\n`; el.scrollTop = el.scrollHeight; } @@ -3387,11 +3523,8 @@ async function initScanner() { console.error('Camera error:', err); document.getElementById('scan-result').style.display = 'block'; document.getElementById('scan-result').innerHTML = ` -

⚠️ Impossibile accedere alla fotocamera.

-

- Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.
- Puoi inserire il barcode manualmente o usare l'identificazione AI. -

+

${t('error.camera')}

+

${t('scanner.camera_error_hint')}

`; } } @@ -3856,7 +3989,7 @@ function showQuickNameResults(searchName, products) { ${catIcon}
${escapeHtml(p.name)}
-
${p.brand ? escapeHtml(p.brand) + ' · ' : ''}${p.barcode ? '📊 ' + p.barcode : 'Senza barcode'}
+
${p.brand ? escapeHtml(p.brand) + ' · ' : ''}${p.barcode ? '📊 ' + p.barcode : t('product.no_barcode')}
`; item.onclick = () => selectQuickProduct(p); @@ -3932,7 +4065,7 @@ async function createQuickProduct(name) { showProductAction(); } else { showLoading(false); - showToast(result.error || 'Errore nel salvataggio', 'error'); + showToast(result.error || t('error.save'), 'error'); } } catch (err) { showLoading(false); @@ -4128,20 +4261,20 @@ async function scanBarcodeForForm() { contentEl.innerHTML = `
-

Inquadra il codice a barre del prodotto

+

${t('scanner.barcode_hint')}

- + + ">${t('scanner.barcode_use_btn')}
`; overlayEl.style.display = 'flex'; @@ -4221,7 +4354,7 @@ async function submitProduct(e) { showProductAction(); } else { showLoading(false); - showToast(result.error || 'Errore nel salvataggio', 'error'); + showToast(result.error || t('error.save'), 'error'); } } catch (err) { showLoading(false); @@ -4252,7 +4385,7 @@ function showProductAction() { // NOVA group if (currentProduct.nova_group) { - const novaLabels = { '1': 'Non trasformato', '2': 'Ingrediente culinario', '3': 'Trasformato', '4': 'Ultra-trasformato' }; + const novaLabels = { '1': t('nova.1'), '2': t('nova.2'), '3': t('nova.3'), '4': t('nova.4') }; detailsHtml += `
🏭 NOVA ${currentProduct.nova_group}${novaLabels[currentProduct.nova_group] ? ' - ' + novaLabels[currentProduct.nova_group] : ''}
`; } @@ -4336,7 +4469,7 @@ function showProductAction() { ${isUnknown ? '

Inserisci il nome e le informazioni del prodotto

' : ''}
- +
@@ -4350,7 +4483,7 @@ function showProductAction() { ${categoryOptions}
- +
`; @@ -4405,9 +4538,9 @@ function showProductAction() { let expiryStr = ''; if (inv.expiry_date) { const d = daysUntilExpiry(inv.expiry_date); - if (d < 0) expiryStr = ` · ⚠️ Scaduto da ${Math.abs(d)}g`; - else if (d <= 3) expiryStr = ` · 🔴 Scade tra ${d}g`; - else if (d <= 7) expiryStr = ` · 🟡 Scade tra ${d}g`; + if (d < 0) expiryStr = ` · ${t('expiry.badge_expired_ago').replace('{n}', Math.abs(d))}`; + else if (d <= 3) expiryStr = ` · ${t('expiry.badge_expires_red').replace('{n}', d)}`; + else if (d <= 7) expiryStr = ` · ${t('expiry.badge_expires_yellow').replace('{n}', d)}`; else expiryStr = ` · 📅 ${formatDate(inv.expiry_date)}`; } const vacuumIcon = inv.vacuum_sealed ? ' 🫙' : ''; @@ -4419,7 +4552,7 @@ function showProductAction() { statusBar.innerHTML = `
- 📦 Ce l'hai già! + ${t('action.have_title')}
${totalStr} ${totalFrac ? `${totalFrac}` : ''} @@ -4432,19 +4565,19 @@ function showProductAction() { btnsContainer.innerHTML = ` `; // Secondary: catalog edit link below the buttons (one instance only) @@ -4463,7 +4596,7 @@ function showProductAction() { btnsContainer.innerHTML = ` `; // Remove catalog-edit link if left over from a previous product @@ -4581,7 +4714,7 @@ function openInventoryEdit() { let expiryStr = ''; if (inv.expiry_date) { const d = daysUntilExpiry(inv.expiry_date); - expiryStr = ` · ${d < 0 ? '⚠️ Scaduto' : '📅 ' + formatDate(inv.expiry_date)}`; + expiryStr = ` · ${d < 0 ? t('expiry.badge_expired_bare') : '📅 ' + formatDate(inv.expiry_date)}`; } const vacuumStr = inv.vacuum_sealed ? ' 🫙' : ''; return ` @@ -4621,7 +4754,7 @@ function editActionInventoryItem(inventoryId) {
- + @@ -4636,7 +4769,7 @@ function editActionInventoryItem(inventoryId) {
- +
${Object.entries(LOCATIONS).map(([k, v]) => `
- +
@@ -4698,7 +4831,7 @@ async function submitActionEditInventory(e, id, productId) { } async function deleteActionInventoryItem(id) { - if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) { + if (confirm(t('confirm.remove_item'))) { await api('inventory_delete', {}, 'POST', { id }); closeModal(); showToast(t('toast.product_removed'), 'success'); @@ -4729,7 +4862,7 @@ function showThrowForm() { document.getElementById('modal-content').innerHTML = `
@@ -4747,9 +4880,9 @@ function showThrowForm() {
-
oppure specifica la quantità:
+
${t('use.throw_qty_hint')}
@@ -4761,7 +4894,7 @@ function showThrowForm() {
- +
@@ -4769,7 +4902,7 @@ function showThrowForm() {
`; @@ -4795,7 +4928,7 @@ async function throwAll() { }); showLoading(false); if (result.success) { - showToast(`🗑️ ${currentProduct.name} buttato!`, 'success'); + showToast(t('toast.thrown_away', { name: currentProduct.name }), 'success'); showPage('dashboard'); } else { showToast(result.error || 'Errore', 'error'); @@ -4820,7 +4953,7 @@ async function throwPartial() { }); showLoading(false); if (result.success) { - showToast(`🗑️ Buttato ${qty} ${currentProduct.unit || 'pz'} di ${currentProduct.name}`, 'success'); + showToast(t('toast.thrown_away_partial', { qty, unit: currentProduct.unit || 'pz', name: currentProduct.name }), 'success'); showPage('dashboard'); } else { showToast(result.error || 'Errore', 'error'); @@ -4869,11 +5002,11 @@ async function saveEditedProductInfo() { currentProduct.name = name; currentProduct.brand = brand; if (category) currentProduct.category = category; - showToast('✅ Prodotto aggiornato!', 'success'); + showToast(t('toast.product_updated'), 'success'); // Refresh the action page with updated data showProductAction(); } else { - showToast(result.error || 'Errore nel salvataggio', 'error'); + showToast(result.error || t('error.save'), 'error'); } } catch (err) { showLoading(false); @@ -4972,25 +5105,25 @@ function showAddForm() { window._addBaseExpiryDays = estimatedDays; expirySection.innerHTML = ` - +
- Scadenza stimata: ${estimateLabel}${expirySuffix} + ${t('add.estimated_expiry')} ${estimateLabel}${expirySuffix} ${formatDate(estimatedDate)}
- +
-

📝 Puoi modificare la data o scansionarla con la fotocamera

+

${t('add.hint_modify')}

@@ -5035,7 +5168,7 @@ function recalculateAddExpiry() { let suffix = ''; if (window._historyExpiryDays) suffix = ' (da storico)'; - else if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)'; + else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum'); else if (loc === 'freezer') suffix = ' (freezer)'; else if (isVacuum) suffix = ' (sotto vuoto)'; @@ -5043,7 +5176,7 @@ function recalculateAddExpiry() { const estimateEl = document.querySelector('.expiry-estimate-label'); const dateEl = document.querySelector('.expiry-estimate-date'); if (expiryInput) expiryInput.value = newDate; - if (estimateEl) estimateEl.innerHTML = `Scadenza stimata: ${newLabel}${suffix}`; + if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} ${newLabel}${suffix}`; if (dateEl) dateEl.textContent = formatDate(newDate); } @@ -5065,7 +5198,7 @@ async function _fetchExpiryHistoryAndUpdate(productId) { const estimateEl = document.querySelector('.expiry-estimate-label'); const dateEl = document.querySelector('.expiry-estimate-date'); if (expiryInput) expiryInput.value = newDate; - if (estimateEl) estimateEl.innerHTML = `Scadenza stimata: ${newLabel}${suffix}`; + if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} ${newLabel}${suffix}`; if (dateEl) dateEl.textContent = formatDate(newDate); window._addBaseExpiryDays = data.avg_days; } @@ -5190,20 +5323,20 @@ function selectPurchaseType(btn, type) { const estimateLabel = formatEstimatedExpiry(days); let suffix = ''; if (window._historyExpiryDays) suffix = ` 📊 storico`; - else if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)'; - else if (loc === 'freezer') suffix = ' (freezer)'; - else if (isVacuum) suffix = ' (sotto vuoto)'; + 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'); detailDiv.innerHTML = `
- Scadenza stimata: ${estimateLabel}${suffix} + ${t('add.estimated_expiry')} ${estimateLabel}${suffix} ${formatDate(estimatedDate)}
- +
-

📝 Puoi modificare la data o scansionarla con la fotocamera

+

${t('add.hint_modify')}

`; // Restore quantity - switching purchase type should NOT change it document.getElementById('add-quantity').value = currentQty; @@ -5216,17 +5349,17 @@ function selectPurchaseType(btn, type) {
- +

Inserisci la data di scadenza o scansionala

- -

Quanto è rimasto approssimativamente?

+ +

${t('add.remaining_hint')}

- + - +
@@ -5348,7 +5481,7 @@ async function submitAdd(e) { qtyInfo = ` (totale: ${result.total_qty} ${uLabel})`; } } - showToast(`✅ ${currentProduct.name} aggiunto!${qtyInfo}`, 'success'); + showToast(t('add.product_added').replace('{name}', currentProduct.name).replace('{qty}', qtyInfo), 'success'); if (result.removed_from_bring) { setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500); } else if (shoppingItems.length > 0 && shoppingListUUID) { @@ -5472,19 +5605,19 @@ function _renderUseExpiryHint(items) { const diffDays = Math.round((expDate - today) / 86400000); const locInfo = LOCATIONS[soonest.location] || { icon: '📦', label: soonest.location }; - const dateStr = expDate.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit' }); + const dateStr = expDate.toLocaleDateString(_currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT', { day: '2-digit', month: '2-digit' }); let whenStr; - if (diffDays < 0) whenStr = `scaduta da ${-diffDays} giorn${-diffDays === 1 ? 'o' : 'i'}`; - else if (diffDays === 0) whenStr = 'scade oggi'; - else if (diffDays === 1) whenStr = 'scade domani'; - else whenStr = `scade tra ${diffDays} giorni`; + if (diffDays < 0) whenStr = t('use.when_expired').replace('{n}', -diffDays); + else if (diffDays === 0) whenStr = t('use.when_today'); + else if (diffDays === 1) whenStr = t('use.when_tomorrow'); + else whenStr = t('use.when_days').replace('{n}', diffDays); const locLabel = uniqueLocs.size > 1 ? ` (${locInfo.icon} ${locInfo.label})` : ''; - hintEl.innerHTML = `⚠️ Usa prima quella${locLabel} che scade il ${dateStr} — ${whenStr}!`; + hintEl.innerHTML = t('use.expiry_warning').replace('{loc}', locLabel).replace('{date}', `${dateStr}`).replace('{when}', whenStr); hintEl.style.display = 'block'; } @@ -5600,7 +5733,7 @@ async function loadUseInventoryInfo() { qtyInput.value = 1; qtyInput.step = 'any'; qtyInput.min = '0.01'; - document.getElementById('use-partial-hint').textContent = 'Oppure specifica la quantità usata:'; + document.getElementById('use-partial-hint').textContent = t('use.partial_hint'); // Fraction buttons for pz unit const existingFrac = document.getElementById('pz-fraction-btns'); @@ -5816,10 +5949,38 @@ function _matchBringToSmart(bringName, smartItems) { function showLowStockBringPrompt(result, afterCallback) { const name = result.product_name || currentProduct?.name || ''; + // Generic shopping name (e.g. "Affettato" for "Mortadella IGP"). Falls back to + // the specific name when shopping_name is not set (older API call), so behaviour + // is unchanged for legacy callers. + const shoppingName = result.product_shopping_name || name; const unit = result.product_unit || currentProduct?.unit || 'pz'; const defaultQty = result.product_default_qty || parseFloat(currentProduct?.default_quantity) || 0; const totalRemaining = result.total_remaining; + // ── 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). + if (totalRemaining <= 0) { + // Backend auto-adds to Bring! when fully depleted. If it failed (Bring not + // configured, or product already on list), silently attempt it from JS. + if (!result.added_to_bring && shoppingName) { + // Fire-and-forget — don't block the callback + // Use generic shopping name; specific name goes into specification. + const spec = shoppingName !== name ? name + (result.product_brand ? ` · ${result.product_brand}` : '') : ''; + (async () => { + try { + const payload = { items: [{ name: shoppingName, specification: spec }] }; + if (shoppingListUUID) payload.listUUID = shoppingListUUID; + const data = await api('bring_add', {}, 'POST', payload); + if (data.success && data.added > 0) { + showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'); + } + } catch(_e) { /* silent */ } + })(); + } + if (afterCallback) afterCallback(); + return; + } + if (!isLowStock(totalRemaining, unit, defaultQty)) { if (afterCallback) afterCallback(); return; @@ -5837,30 +5998,36 @@ function showLowStockBringPrompt(result, afterCallback) { // --- Deduplication check --- // 1. Already on Bring! list (shoppingItems)? - const alreadyOnBring = _findSimilarItem(name, shoppingItems); + const alreadyOnBring = _findSimilarItem(shoppingName, shoppingItems) || _findSimilarItem(name, shoppingItems); if (alreadyOnBring) { // Already present (same or similar item). Just inform and continue. - showToast(`🛒 "${escapeHtml(alreadyOnBring.name)}" già nella lista della spesa`, 'info'); + showToast(t('shopping.already_in_list', { name: escapeHtml(alreadyOnBring.name) }), 'info'); if (afterCallback) afterCallback(); return; } // 2. In smart shopping predictions? - const smartMatch = _findSimilarItem(name, smartShoppingItems); + const smartMatch = _findSimilarItem(shoppingName, smartShoppingItems) || _findSimilarItem(name, smartShoppingItems); const smartUrgencyLabel = { - critical: '🔴 Urgente', high: '🟠 Presto', medium: '🟡 Pianifica', low: '🟢 Previsione' + critical: t('shopping.urgency_critical'), high: t('shopping.urgency_high'), + medium: t('shopping.urgency_medium'), low: t('shopping.urgency_low') }; let smartNote = ''; if (smartMatch) { const lbl = smartUrgencyLabel[smartMatch.urgency] || ''; + const _smartMsg = t('shopping.smart_already_predicted').replace('{name}', escapeHtml(smartMatch.name)).replace('{urgency}', lbl ? ` (${lbl})` : ''); smartNote = `
- 📊 La spesa intelligente prevede già ${escapeHtml(smartMatch.name)}${lbl ? ` (${lbl})` : ''}. + ${_smartMsg}
`; } - // Build specification from product name for Bring + // _lowStockName = generic name that goes into Bring! (e.g. "Affettato") + // _lowStockSpec = specific product name used as specification (e.g. "Mortadella IGP") window._lowStockAfterCallback = afterCallback; - window._lowStockSpec = name; + window._lowStockName = shoppingName; + window._lowStockSpec = shoppingName !== name + ? name + (result.product_brand ? ` · ${result.product_brand}` : '') + : name; document.getElementById('modal-content').innerHTML = `
-

${escapeHtml(name)} sta per finire — rimangono solo ${remainLabel}.

+

${t('lowstock.message').replace('{name}', `${escapeHtml(name)}`).replace('{qty}', `${remainLabel}`)}

${smartNote} -

Vuoi aggiungerlo alla lista della spesa?

-
`; document.getElementById('modal-overlay').style.display = 'flex'; } -async function addLowStockToBring(productName) { +async function addLowStockToBring() { closeModal(); try { + // Use the generic shopping name (e.g. "Affettato") set by showLowStockBringPrompt. + // _lowStockSpec holds the specific product name (e.g. "Mortadella IGP · Marca"). + const bringName = window._lowStockName || ''; const spec = window._lowStockSpec || ''; + window._lowStockName = null; window._lowStockSpec = null; - const payload = { items: [{ name: productName, specification: spec }] }; + const payload = { items: [{ name: bringName, specification: spec }] }; if (shoppingListUUID) payload.listUUID = shoppingListUUID; const data = await api('bring_add', {}, 'POST', payload); if (data.success && data.added > 0) { @@ -5948,18 +6119,18 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId) { const vacuumRow = wasVacuum ? ` ` : ''; document.getElementById('modal-content').innerHTML = `
-

Vuoi spostare ${openedId ? 'la confezione aperta' : 'il resto'} di ${escapeHtml(product.name)} in un'altra posizione?

+

${t('move.question').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest')).replace('{name}', `${escapeHtml(product.name)}`)}

${locButtons}
${vacuumRow} - +
`; document.getElementById('modal-overlay').style.display = 'flex'; @@ -5983,7 +6154,7 @@ async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) { product_id: productId, vacuum_sealed: newVacuum, }); - showToast(`📦 Confezione aperta spostata in ${LOCATIONS[toLoc]?.label || toLoc}`, 'success'); + showToast(t('move.moved_toast').replace('{location}', LOCATIONS[toLoc]?.label || toLoc), 'success'); } else { // Legacy: move whatever is at fromLoc const data = await api('inventory_list'); @@ -6021,7 +6192,7 @@ async function submitUseAll() { if (result.success) { showToast(`📤 ${currentProduct.name} terminato!`, 'success'); if (result.added_to_bring) { - setTimeout(() => showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'), 1500); + setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500); } // Check low stock (product may exist at other locations) showLowStockBringPrompt(result, () => showPage('dashboard')); @@ -6038,7 +6209,12 @@ async function submitUse(e) { e.preventDefault(); if (_useSubmitting) return; // prevent double-submit from scale auto-confirm _useSubmitting = true; - _cancelScaleAutoConfirm(false); // stop any running auto-confirm + // Stop timers but KEEP _scaleLastConfirmedGrams: this prevents the scale from + // re-triggering another auto-submit while the product is still on the plate. + // (Calling _cancelScaleAutoConfirm(false) would reset the sentinel to null, + // allowing the same weight to start a new 10-second cycle immediately.) + _cancelScaleTimersOnly(); + _scaleStabilityVal = null; // reset sentinel so a new DIFFERENT weight restarts correctly showLoading(true); try { let qty = parseFloat(document.getElementById('use-quantity').value) || 1; @@ -6062,7 +6238,7 @@ async function submitUse(e) { _useSubmitting = false; if (result.success) { const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty; - showToast(`📤 Usato ${usedText} di ${currentProduct.name}`, 'success'); + showToast(t('use.toast_used').replace('{qty}', usedText).replace('{name}', currentProduct.name), 'success'); if (result.added_to_bring) { setTimeout(() => showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'), 1500); } @@ -6074,6 +6250,8 @@ async function submitUse(e) { : () => showPage('dashboard'); // Check low stock → Bring! prompt showLowStockBringPrompt(result, moveCallback); + } else if (result.duplicate) { + // Silently ignore: this was a scale double-trigger, not a real error } else { showToast(result.error || 'Errore', 'error'); } @@ -6154,7 +6332,7 @@ function retakePhotoAI() { async function analyzeWithAI() { const resultDiv = document.getElementById('ai-result'); resultDiv.style.display = 'block'; - resultDiv.innerHTML = '

🤖 Identifico il prodotto...

'; + resultDiv.innerHTML = `

${t('scanner.ai_identifying')}

`; const canvas = document.getElementById('ai-canvas'); const base64 = canvas.toDataURL('image/jpeg', 0.7).split(',')[1]; @@ -6165,9 +6343,12 @@ async function analyzeWithAI() { if (!result.success) { if (result.error === 'no_api_key') { resultDiv.innerHTML = `

⚠️ Chiave API Gemini non configurata.
Aggiungi GEMINI_API_KEY nel file .env sul server.

`; + } else if (/resource.?exhaust|quota|rate.?limit/i.test(result.error || '')) { + resultDiv.innerHTML = `

⏳ ${t('error.ai_quota')}

+ `; } else { - resultDiv.innerHTML = `

❌ ${escapeHtml(result.error || 'Errore nell\'identificazione')}

- `; + resultDiv.innerHTML = `

❌ ${escapeHtml(result.error || t('error.identification'))}

+ `; } return; } @@ -6202,7 +6383,7 @@ async function analyzeWithAI() { // Show existing local products first if (localMatches.length > 0) { - html += `

📋 Già in dispensa

`; + html += `

${t('product.already_in_pantry')}

`; html += `
`; localMatches.forEach((p, idx) => { html += `
`; @@ -6252,8 +6433,8 @@ async function analyzeWithAI() { } catch (err) { console.error('AI identify error:', err); - resultDiv.innerHTML = `

❌ Errore di connessione

- `; + resultDiv.innerHTML = `

❌ ${t('error.connection')}

+ `; } } @@ -6380,7 +6561,7 @@ async function saveAIProductDirect() { showProductAction(); } else { showLoading(false); - showToast(result.error || 'Errore nel salvataggio', 'error'); + showToast(result.error || t('error.save'), 'error'); } } catch (err) { showLoading(false); @@ -6408,13 +6589,13 @@ async function captureForAIFormFill() {
-

Inquadra l'etichetta del prodotto

+

${t('scanner.product_label_hint')}

- - + +
`; @@ -6427,7 +6608,7 @@ async function captureForAIFormFill() { await video.play(); } catch (err) { document.getElementById('pfai-cam-container').innerHTML = - `

⚠️ Impossibile accedere alla fotocamera

`; + `

${t('error.camera')}

`; } } @@ -6487,8 +6668,13 @@ async function _pfAiAnalyze(base64) { resultEl.style.display = 'block'; if (!result.success) { - resultEl.innerHTML = `

❌ ${escapeHtml(result.error || 'Errore identificazione')}

- `; + if (/resource.?exhaust|quota|rate.?limit/i.test(result.error || '')) { + resultEl.innerHTML = `

⏳ ${t('error.ai_quota')}

+ `; + } else { + resultEl.innerHTML = `

❌ ${escapeHtml(result.error || t('error.identification'))}

+ `; + } return; } @@ -6515,7 +6701,7 @@ async function _pfAiAnalyze(base64) { html += `
`; } - html += ``; + html += ``; resultEl.innerHTML = html; window._pfAiIdentified = id; @@ -6525,7 +6711,7 @@ async function _pfAiAnalyze(base64) { statusEl.style.display = 'none'; resultEl.style.display = 'block'; resultEl.innerHTML = `

❌ Errore di connessione

- `; + `; } } @@ -6605,7 +6791,7 @@ async function searchAllProducts() { function renderProductsList(products) { const container = document.getElementById('products-list'); if (products.length === 0) { - container.innerHTML = '
📦

Nessun prodotto nel database.
Scansiona un prodotto per iniziare!

'; + container.innerHTML = `
📦

${t('inventory.empty_db')}

`; return; } container.innerHTML = products.map(p => { @@ -6700,7 +6886,7 @@ function toggleShoppingTag(itemIdx, tag) { // Sync urgente/presto tag to Bring specification so it's visible in the Bring app if (tag === 'urgente' && shoppingListUUID) { const isNowUrgent = existing.includes('urgente'); - const newSpec = isNowUrgent ? '⚡ Urgente' : ''; + const newSpec = isNowUrgent ? t('shopping.urgency_spec_critical') : ''; api('bring_add', {}, 'POST', { items: [{ name: item.name, specification: newSpec, update_spec: true }], listUUID: shoppingListUUID, @@ -6719,7 +6905,7 @@ function openScanForItem(idx) { if (!item) return; _spesaScanTarget = { name: item.name, rawName: item.rawName || '', idx }; showPage('scan'); - showToast(`📷 Scansiona: ${item.name}`, 'info'); + showToast(t('shopping.scan_toast').replace('{name}', item.name), 'info'); } async function confirmShoppingItemFound() { @@ -6732,7 +6918,7 @@ async function confirmShoppingItemFound() { if (r.success) { const idx = shoppingItems.findIndex(i => i.name.toLowerCase() === name.toLowerCase()); if (idx >= 0) shoppingItems.splice(idx, 1); - showToast(`✅ ${name} rimosso dalla lista!`, 'success'); + showToast(t('shopping.item_removed').replace('{name}', name), 'success'); logOperation('bring_found', { name }); loadShoppingCount(); } @@ -6744,7 +6930,7 @@ async function confirmShoppingItemFound() { /** Build a Bring specification string that encodes urgency + optional brand. */ function _urgencyToSpec(urgency, brand) { - const urgencyLabels = { critical: '⚡ Urgente', high: '🟠 Presto', medium: '', low: '' }; + const urgencyLabels = { critical: t('shopping.urgency_spec_critical'), high: t('shopping.urgency_spec_high'), medium: '', low: '' }; const urgLabel = urgencyLabels[urgency] || ''; if (urgLabel && brand) return `${urgLabel} · ${brand}`; if (urgLabel) return urgLabel; @@ -6817,7 +7003,7 @@ async function autoAddCriticalItems() { try { const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); if (result.success && result.added > 0) { - showToast(`🔴 ${result.added} prodott${result.added === 1 ? 'o urgente aggiunto' : 'i urgenti aggiunti'} automaticamente a Bring!`, 'success'); + showToast(t('shopping.add_urgent_toast', { n: result.added }), 'success'); logOperation('bring_auto_add', { added: itemsToAdd.map(i => i.name) }); loadShoppingList(); } @@ -6921,7 +7107,7 @@ async function cleanupObsoleteBringItems() { } if (removed > 0) { - showToast(`🧹 ${removed} prodott${removed === 1 ? 'o con scorte sufficienti rimosso' : 'i con scorte sufficienti rimossi'} dalla lista`, 'info'); + showToast(t('shopping.removed_sufficient', { removed }), 'info'); logOperation('bring_cleanup', { removed: removedNames }); loadShoppingList(); } @@ -7065,12 +7251,13 @@ function _syncOnBringFlags() { for (const si of smartShoppingItems) { const siLower = si.name.toLowerCase(); const siFirst = _nameTokens(si.name)[0]; + const siShoppingLower = (si.shopping_name || '').toLowerCase(); + const siShoppingFirst = si.shopping_name ? _nameTokens(si.shopping_name)[0] : null; si.on_bring = !!( shoppingItems.find(bi => bi.name.toLowerCase() === siLower) || - (siFirst && shoppingItems.find(bi => { - const biFirst = _nameTokens(bi.name)[0]; - return biFirst === siFirst; - })) + (siShoppingLower && shoppingItems.find(bi => bi.name.toLowerCase() === siShoppingLower)) || + (siFirst && shoppingItems.find(bi => _nameTokens(bi.name)[0] === siFirst)) || + (siShoppingFirst && shoppingItems.find(bi => _nameTokens(bi.name)[0] === siShoppingFirst)) ); } } @@ -7143,16 +7330,16 @@ function renderSmartShopping() { countEl.textContent = items.length; if (items.length === 0) { - container.innerHTML = '

Nessun prodotto in questa categoria

'; + container.innerHTML = `

${t('shopping.empty_category')}

`; actionsEl.style.display = 'none'; return; } const urgencyConfig = { - critical: { color: '#ef4444', bg: 'rgba(239,68,68,0.08)', icon: '🔴', label: 'Urgente' }, - high: { color: '#f97316', bg: 'rgba(249,115,22,0.08)', icon: '🟠', label: 'Presto' }, - medium: { color: '#eab308', bg: 'rgba(234,179,8,0.08)', icon: '🟡', label: 'Pianifica' }, - low: { color: '#22c55e', bg: 'rgba(34,197,94,0.08)', icon: '🟢', label: 'Previsione' }, + critical: { color: '#ef4444', bg: 'rgba(239,68,68,0.08)', icon: '🔴', label: t('shopping.urgency_critical') }, + high: { color: '#f97316', bg: 'rgba(249,115,22,0.08)', icon: '🟠', label: t('shopping.urgency_high') }, + medium: { color: '#eab308', bg: 'rgba(234,179,8,0.08)', icon: '🟡', label: t('shopping.urgency_medium') }, + low: { color: '#22c55e', bg: 'rgba(34,197,94,0.08)', icon: '🟢', label: t('shopping.urgency_low') }, }; // Group by section @@ -7181,15 +7368,36 @@ function renderSmartShopping() { function renderSmartItem(item) { const urgencyConfig = { - critical: { color: '#ef4444', bg: 'rgba(239,68,68,0.08)', icon: '🔴', label: 'Urgente' }, - high: { color: '#f97316', bg: 'rgba(249,115,22,0.08)', icon: '🟠', label: 'Presto' }, - medium: { color: '#eab308', bg: 'rgba(234,179,8,0.08)', icon: '🟡', label: 'Pianifica' }, - low: { color: '#22c55e', bg: 'rgba(34,197,94,0.08)', icon: '🟢', label: 'Previsione' }, + critical: { color: '#ef4444', bg: 'rgba(239,68,68,0.08)', icon: '🔴', label: t('shopping.urgency_critical') }, + high: { color: '#f97316', bg: 'rgba(249,115,22,0.08)', icon: '🟠', label: t('shopping.urgency_high') }, + medium: { color: '#eab308', bg: 'rgba(234,179,8,0.08)', icon: '🟡', label: t('shopping.urgency_medium') }, + low: { color: '#22c55e', bg: 'rgba(34,197,94,0.08)', icon: '🟢', label: t('shopping.urgency_low') }, }; const u = urgencyConfig[item.urgency] || urgencyConfig.low; const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦'; const globalIdx = smartShoppingItems.indexOf(item); + // Generic vs specific name logic + const shoppingName = item.shopping_name || item.name; + const isGeneric = shoppingName !== item.name; + const variants = item.variants || []; + + // Build title line: generic name (and brand only if not grouped) + let nameLine = `
${escapeHtml(shoppingName)}`; + if (!isGeneric && item.brand) nameLine += ` ${escapeHtml(item.brand)}`; + nameLine += `
`; + + // Build subtitle: specific product + brand when grouped, plus any variants + let specificLine = ''; + if (isGeneric || variants.length > 0) { + let specifics = []; + specifics.push(item.name + (item.brand ? ` (${item.brand})` : '')); + for (const v of variants) { + specifics.push(v.name + (v.brand ? ` (${v.brand})` : '')); + } + specificLine = `
${escapeHtml(specifics.join(' · '))}
`; + } + // Stock bar const pct = Math.min(100, Math.max(0, item.pct_left)); const barColor = pct <= 15 ? '#ef4444' : pct <= 30 ? '#f97316' : pct <= 50 ? '#eab308' : '#22c55e'; @@ -7200,29 +7408,29 @@ function renderSmartItem(item) { qtyText = `${item.current_qty} ${item.unit}`; if (item.pct_left < 100) qtyText += ` (${pct}%)`; } else { - qtyText = 'Esaurito'; + qtyText = t('shopping.out_of_stock'); } // Usage frequency badge let freqBadge = ''; - if (item.use_count >= 8) freqBadge = '📈 Uso frequente'; - else if (item.use_count >= 4) freqBadge = '📊 Uso regolare'; - else if (item.use_count >= 2) freqBadge = '📉 Uso occasionale'; + if (item.use_count >= 8) freqBadge = `${t('shopping.freq_high')}`; + else if (item.use_count >= 4) freqBadge = `${t('shopping.freq_regular')}`; + else if (item.use_count >= 2) freqBadge = `${t('shopping.freq_occasional')}`; // Days left prediction let predBadge = ''; if (item.days_left <= 3 && item.days_left > 0 && item.current_qty > 0) { - predBadge = `⏳ ~${item.days_left}gg rimasti`; + predBadge = `${t('expiry.badge_days_left').replace('{n}', item.days_left)}`; } else if (item.days_left <= 7 && item.days_left > 0 && item.current_qty > 0) { - predBadge = `⏳ ~${item.days_left}gg rimasti`; + predBadge = `${t('expiry.badge_days_left').replace('{n}', item.days_left)}`; } // Expiry badge let expiryBadge = ''; if (item.days_to_expiry < 0 && item.current_qty > 0) { - expiryBadge = `⚠️ Scaduto`; + expiryBadge = `${t('expiry.badge_expired_bare')}`; } else if (item.days_to_expiry <= 3 && item.days_to_expiry >= 0 && item.current_qty > 0) { - expiryBadge = `⚠️ Scade tra ${item.days_to_expiry}gg`; + expiryBadge = `${t('expiry.badge_expires_warn').replace('{n}', item.days_to_expiry)}`; } return ` @@ -7231,13 +7439,14 @@ function renderSmartItem(item) { ${!item.on_bring ? `` : ''} ${catIcon}
-
${escapeHtml(item.name)}${item.brand ? ` ${escapeHtml(item.brand)}` : ''}
+ ${nameLine} + ${specificLine}
${item.reasons.map(r => `${escapeHtml(r)}`).join(' · ')}
${u.icon} ${u.label} ${freqBadge}${predBadge}${expiryBadge} - ${item.is_opened ? '📭 Aperto' : ''} - ${item.on_bring ? '🛒 Già su Bring!' : ''} + ${item.is_opened ? `${t('inventory.opened_badge')}` : ''} + ${item.on_bring ? `${t('shopping.bring_badge')}` : ''}
@@ -7248,6 +7457,30 @@ function renderSmartItem(item) {
`; } +async function migrateBringNames(btn) { + const statusEl = document.getElementById('bring-migrate-status'); + if (btn) btn.disabled = true; + if (statusEl) { statusEl.style.display = 'inline'; statusEl.textContent = '⏳ In corso…'; } + try { + const data = await api('bring_migrate_names', {}, 'POST', {}); + if (data.success) { + const msg = t('shopping.migration_done', { migrated: data.migrated, skipped: data.skipped }) + (data.errors ? `, ${data.errors} errori` : ''); + if (statusEl) statusEl.textContent = msg; + if (data.migrated > 0) { + showToast(`🔄 ${data.migrated} nomi generalizzati in Bring!`, 'success'); + loadShoppingList(); // refresh the shopping list view + } else { + showToast('Tutti i nomi sono già aggiornati', 'info'); + } + } else { + if (statusEl) statusEl.textContent = '❌ ' + (data.error || 'Errore'); + } + } catch(e) { + if (statusEl) statusEl.textContent = '❌ Errore di connessione'; + } + if (btn) btn.disabled = false; +} + async function addSmartToBring() { const checks = document.querySelectorAll('.smart-check:checked'); if (checks.length === 0) { @@ -7260,9 +7493,15 @@ async function addSmartToBring() { const idx = parseInt(cb.dataset.idx); const item = smartShoppingItems[idx]; if (item) { + const shoppingName = item.shopping_name || item.name; + const isGeneric = shoppingName !== item.name; + // When generic, use specific product name + brand as the specification + const spec = isGeneric + ? (item.name + (item.brand ? ` · ${item.brand}` : '')) + : _urgencyToSpec(item.urgency, item.brand); itemsToAdd.push({ - name: item.name, - specification: _urgencyToSpec(item.urgency, item.brand), + name: shoppingName, + specification: spec, }); } }); @@ -7276,8 +7515,8 @@ async function addSmartToBring() { showLoading(false); if (result.success) { const msg = result.added > 0 - ? `🛒 ${result.added} prodotti aggiunti a Bring!${result.skipped > 0 ? ` (${result.skipped} già presenti)` : ''}` - : `Tutti i prodotti erano già su Bring!`; + ? t('shopping.added_to_bring', { n: result.added }) + (result.skipped > 0 ? ` (${t('shopping.added_to_bring_skip', { n: result.skipped })})` : '') + : t('shopping.all_on_bring'); showToast(msg, result.added > 0 ? 'success' : 'info'); // Reload to refresh badges loadShoppingList(); @@ -7445,7 +7684,7 @@ async function loadShoppingList() { } catch (err) { console.error('Bring! error:', err); statusEl.style.display = 'block'; - statusEl.innerHTML = '
⚠️ Errore di connessione a Bring!
'; + statusEl.innerHTML = `
${t('error.bring_connection')}
`; } } @@ -7500,12 +7739,12 @@ async function renderShoppingItems() { } // Build section groups, sorted by urgency weight within each section - const TAG_LABELS = { urgente: '🔴 Urgente', prio: '⭐ Priorità', check: '✅ Verificare' }; + const TAG_LABELS = { urgente: t('shopping.tag_urgent'), prio: t('shopping.tag_priority'), check: t('shopping.tag_check') }; const urgencyMap = { - critical: { icon: '🔴', label: 'Urgente', cls: 'badge-critical' }, - high: { icon: '🟠', label: 'Presto', cls: 'badge-high' }, - medium: { icon: '🟡', label: 'Medio', cls: 'badge-medium' }, - low: { icon: '🟢', label: 'Ok', cls: 'badge-low' }, + critical: { icon: '🔴', label: t('shopping.urgency_critical'), cls: 'badge-critical' }, + high: { icon: '🟠', label: t('shopping.urgency_high'), cls: 'badge-high' }, + medium: { icon: '🟡', label: t('shopping.urgency_medium_short'), cls: 'badge-medium' }, + low: { icon: '🟢', label: t('shopping.urgency_low_short'), cls: 'badge-low' }, }; // Map each item to its section + urgency (strict first-token matching to avoid false positives) @@ -7606,7 +7845,7 @@ async function renderShoppingItems() { } else if (priceData && priceData.searched && !priceData.product) { detailHtml = `
Non trovato
`; spesaBar = `
- +
`; } else { spesaBar = `
@@ -7971,7 +8210,7 @@ async function scanExpiryWithAI() { // Create modal for camera capture document.getElementById('modal-content').innerHTML = `
@@ -7983,14 +8222,14 @@ async function scanExpiryWithAI() { -

Inquadra la data di scadenza stampata sul prodotto

+

${t('scanner.expiry_label_hint')}

- - + +
`; @@ -8071,7 +8310,7 @@ function retakeExpiry() { async function analyzeExpiryImage(dataUrl) { const statusDiv = document.getElementById('expiry-scan-status'); statusDiv.style.display = 'block'; - statusDiv.innerHTML = '

🤖 Analisi AI in corso...

'; + statusDiv.innerHTML = `

${t('scanner.ai_analyzing')}

`; try { // Remove data:image/jpeg;base64, prefix @@ -8093,12 +8332,12 @@ async function analyzeExpiryImage(dataUrl) { statusDiv.innerHTML = `

⚠️ Chiave API Gemini non configurata.
Aggiungi GEMINI_API_KEY nel file .env sul server.

`; } else { statusDiv.innerHTML = `

❌ Non riesco a leggere la data. ${result.raw_text ? '
Letto: ' + escapeHtml(result.raw_text) + '' : ''}

- `; + `; } } catch (err) { console.error('Expiry AI error:', err); - statusDiv.innerHTML = `

❌ Errore di connessione. Riprova.

- `; + statusDiv.innerHTML = `

❌ ${t('error.network_retry')}

+ `; } } @@ -8112,14 +8351,16 @@ function escapeHtml(str) { function formatDate(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr + 'T00:00:00'); - return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' }); + const _loc1 = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT'; + return d.toLocaleDateString(_loc1, { day: '2-digit', month: 'short', year: 'numeric' }); } function formatDateTime(dtStr) { if (!dtStr) return ''; const d = new Date(dtStr.replace(' ', 'T')); - return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short' }) + ' ' + - d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const _loc2 = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT'; + return d.toLocaleDateString(_loc2, { day: '2-digit', month: 'short' }) + ' ' + + d.toLocaleTimeString(_loc2, { hour: '2-digit', minute: '2-digit' }); } function daysUntilExpiry(dateStr) { @@ -8166,13 +8407,14 @@ async function loadLog(more = false) { let html = ''; if (!more && txns.length === 0) { - html = '

Nessuna operazione registrata.

'; + html = `

${t('log.empty')}

`; } else { let lastDate = more ? '' : null; - txns.forEach(t => { - const dt = new Date(t.created_at + 'Z'); - const dateStr = dt.toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); - const timeStr = dt.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const _logLocale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT'; + txns.forEach(tx => { + const dt = new Date(tx.created_at + 'Z'); + const dateStr = dt.toLocaleDateString(_logLocale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); + const timeStr = dt.toLocaleTimeString(_logLocale, { hour: '2-digit', minute: '2-digit' }); if (dateStr !== lastDate) { html += `
${dateStr}
`; @@ -8180,42 +8422,42 @@ async function loadLog(more = false) { } let icon, typeLabel, colorClass; - if (t.type === 'bring') { + if (tx.type === 'bring') { icon = '🛒'; - typeLabel = 'Aggiunto a Bring!'; + typeLabel = t('log.type_bring'); colorClass = 'log-bring'; - } else if (t.type === 'in') { + } else if (tx.type === 'in') { icon = '➕'; - typeLabel = 'Aggiunto'; + typeLabel = t('log.type_added'); colorClass = 'log-in'; } else { icon = '➖'; - typeLabel = t.type === 'waste' ? 'Buttato' : 'Usato'; + typeLabel = tx.type === 'waste' ? t('log.type_waste') : t('log.type_used'); colorClass = 'log-out'; } - const brand = t.brand ? ` (${t.brand})` : ''; - const loc = t.location || ''; - const locLabels = { 'frigo': '🧊 Frigo', 'freezer': '❄️ Freezer', 'dispensa': '🗄️ Dispensa' }; - const locStr = t.type === 'bring' ? '' : (locLabels[loc] || ('📍 ' + loc)); - const isAnnotation = (t.notes || '').includes('[Annullato]'); - const isRecipeNote = !isAnnotation && (t.notes || '').startsWith('Ricetta:'); - const notes = t.notes && !isAnnotation && !isRecipeNote ? ` · ${t.notes}` : ''; - const recipeNote = isRecipeNote ? `
🍳 ${escapeHtml(t.notes)}
` : ''; - const undone = t.undone == 1 || isAnnotation; + const brand = tx.brand ? ` (${tx.brand})` : ''; + const loc = tx.location || ''; + const locLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`])); + const locStr = tx.type === 'bring' ? '' : (locLabels[loc] || ('📍 ' + loc)); + const isAnnotation = (tx.notes || '').includes('[Annullato]'); + const isRecipeNote = !isAnnotation && (tx.notes || '').startsWith('Ricetta:'); + const notes = tx.notes && !isAnnotation && !isRecipeNote ? ` · ${tx.notes}` : ''; + const recipeNote = isRecipeNote ? `
🍳 ${escapeHtml(tx.notes)}
` : ''; + const undone = tx.undone == 1 || isAnnotation; // Can undo if within 24h, not already undone, not a bring entry, not a counter-transaction - const ageMs = Date.now() - new Date(t.created_at + 'Z').getTime(); - const canUndo = !undone && t.type !== 'bring' && ageMs < 86400000; + const ageMs = Date.now() - new Date(tx.created_at + 'Z').getTime(); + const canUndo = !undone && tx.type !== 'bring' && ageMs < 86400000; - html += `
`; + html += `
`; html += `${icon}`; html += `
`; - html += `
${escapeHtml(t.name)}${brand}${undone ? ' Annullato' : ''}
`; - html += `
${typeLabel} ${t.type !== 'bring' ? (t.quantity + ' ' + (t.unit || '')) + ' · ' : ''}${locStr}${notes} · ${timeStr}
`; + html += `
${escapeHtml(tx.name)}${brand}${undone ? ` ${t('log.undone_badge')}` : ''}
`; + html += `
${typeLabel} ${tx.type !== 'bring' ? (tx.quantity + ' ' + (tx.unit || '')) + ' · ' : ''}${locStr}${notes} · ${timeStr}
`; html += recipeNote; html += `
`; if (canUndo) { - html += ``; + html += ``; } html += `
`; }); @@ -8232,17 +8474,17 @@ async function loadLog(more = false) { } catch (err) { console.error('Log load error:', err); - if (!more) document.getElementById('log-list').innerHTML = '

Errore nel caricamento log

'; + if (!more) document.getElementById('log-list').innerHTML = `

${t('log.load_error')}

`; } } async function undoTransactionEntry(id, type, name) { - const action = type === 'in' ? 'rimozione di' : 'ripristino di'; - if (!confirm(`Annullare questa operazione?\n→ ${action} ${name}`)) return; + const action = type === 'in' ? t('log.undo_action_remove') : t('log.undo_action_restore'); + if (!confirm(t('log.undo_confirm').replace('{action}', action).replace('{name}', name))) return; try { const res = await api('transaction_undo', {}, 'POST', { id }); if (res.success) { - showToast(`↩ Operazione annullata per ${res.name || name}`, 'success'); + showToast(t('log.undo_success').replace('{name}', res.name || name), 'success'); // Mark the entry visually without reloading all const el = document.getElementById(`log-entry-${id}`); if (el) { @@ -8251,18 +8493,18 @@ async function undoTransactionEntry(id, type, name) { if (undoBtn) undoBtn.remove(); const nameEl = el.querySelector('.log-product strong'); if (nameEl && !el.querySelector('.log-undone-badge')) { - nameEl.insertAdjacentHTML('afterend', ' Annullato'); + nameEl.insertAdjacentHTML('afterend', ` ${t('log.undone_badge')}`); } } } else if (res.already_undone) { - showToast('Operazione già annullata', 'info'); + showToast(t('log.already_undone'), 'info'); } else if (res.too_old) { - showToast('Non è possibile annullare operazioni più vecchie di 24 ore', 'error'); + showToast(t('log.too_old'), 'error'); } else { - showToast(res.error || 'Errore durante l\'annullamento', 'error'); + showToast(res.error || t('log.undo_error'), 'error'); } } catch (e) { - showToast('Errore di connessione', 'error'); + showToast(t('error.network'), 'error'); } } @@ -8293,7 +8535,7 @@ const MEAL_PLAN_TYPES = [ const MEAL_PLAN_TYPE_MAP = {}; MEAL_PLAN_TYPES.forEach(t => { MEAL_PLAN_TYPE_MAP[t.id] = t; }); -const WEEK_DAYS = ['Lunedì','Martedì','Mercoledì','Giovedì','Venerdì','Sabato','Domenica']; +const WEEK_DAYS = [t('days.mon'),t('days.tue'),t('days.wed'),t('days.thu'),t('days.fri'),t('days.sat'),t('days.sun')]; const WEEK_DAYS_SHORT = ['Lun','Mar','Mer','Gio','Ven','Sab','Dom']; /** Default weekly plan as requested. */ @@ -8524,9 +8766,10 @@ async function loadRecipeArchive() { const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); for (const [date, entries] of Object.entries(byDate)) { - let dateLabel = new Date(date + 'T12:00:00').toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' }); - if (date === today) dateLabel = '📅 Oggi'; - else if (date === yesterday) dateLabel = '📅 Ieri'; + const _mealLocale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT'; + let dateLabel = new Date(date + 'T12:00:00').toLocaleDateString(_mealLocale, { weekday: 'long', day: 'numeric', month: 'long' }); + if (date === today) dateLabel = t('date.today'); + else if (date === yesterday) dateLabel = t('date.yesterday'); html += `
`; html += `
${escapeHtml(dateLabel)}
`; @@ -8870,7 +9113,7 @@ async function submitRecipeUse(useAll) { if (result.success) { const li = document.getElementById(`recipe-ing-${idx}`); if (li) li.classList.add('recipe-ing-used'); - btn.textContent = '✔️ Scalato'; + btn.textContent = t('cooking.ingredient_used'); btn.classList.add('btn-used'); if (_cachedRecipe && _cachedRecipe.recipe && _cachedRecipe.recipe.ingredients && _cachedRecipe.recipe.ingredients[idx]) { @@ -8895,13 +9138,13 @@ async function submitRecipeUse(useAll) { setTimeout(() => showLowStockBringPrompt(result, moveCallback), 300); } else { btn.disabled = false; - btn.textContent = '📦 Usa'; - showToast(result.error || 'Errore nello scalare', 'error'); + btn.textContent = t('cooking.ingredient_use_btn'); + showToast(result.error || t('error.generic'), 'error'); } } catch (err) { console.error('Recipe use error:', err); btn.disabled = false; - btn.textContent = '📦 Usa'; + btn.textContent = t('cooking.ingredient_use_btn'); showToast(t('error.connection'), 'error'); } _recipeUseContext = null; @@ -8915,15 +9158,15 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum) const vacuumRow = wasVacuum ? ` ` : ''; document.getElementById('modal-content').innerHTML = `
-

Vuoi spostare ${openedId ? 'la confezione aperta' : 'il resto'} in un'altra posizione?

+

${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}

${locButtons}
${vacuumRow} @@ -9003,17 +9246,17 @@ function renderRecipe(r) { const exp = new Date(ing.expiry_date); const now = new Date(); now.setHours(0,0,0,0); const diffDays = Math.round((exp - now) / 86400000); - if (diffDays < 0) details.push(`⛔ Scaduto da ${Math.abs(diffDays)}g`); - else if (diffDays <= 3) details.push(`🔴 Scade tra ${diffDays}g`); - else if (diffDays <= 7) details.push(`🟡 Scade tra ${diffDays}g`); - else details.push(`📅 ${exp.toLocaleDateString('it-IT')}`); + if (diffDays < 0) details.push(t('expiry.badge_expired_ago').replace('{n}', Math.abs(diffDays))); + else if (diffDays <= 3) details.push(t('expiry.badge_expires_red').replace('{n}', diffDays)); + else if (diffDays <= 7) details.push(t('expiry.badge_expires_yellow').replace('{n}', diffDays)); + else details.push('📅 ' + exp.toLocaleDateString(_currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT')); } if (details.length) html += `
${details.join(' · ')}`; html += ``; if (alreadyUsed) { - html += ``; + html += ``; } else { - html += ``; + html += ``; } html += ``; } else { @@ -9066,6 +9309,10 @@ function startCookingMode() { document.body.classList.add('cooking-mode-active'); try { screen.orientation?.lock('portrait'); } catch (_) { /* ignore */ } renderCookingStep(); + if (_cookingTTS) { + const text = ((_cookingRecipe.steps || [])[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, ''); + speakCookingStep(text); + } } function closeCookingMode() { document.getElementById('cooking-overlay').style.display = 'none'; @@ -9152,8 +9399,8 @@ function renderCookingStep() { // Timer: detect duration in step text and show suggestion setupCookingTimerSuggestion(cleanStep); - // TTS: only speak when explicitly requested via "Rileggi" button - // (auto-speak removed — use replayCookingTTS() to trigger manually) + // TTS: auto-speak is handled by navigateCookingStep() and startCookingMode() callers. + // Use replayCookingTTS() to re-read the current step manually ("Rileggi" button). } function _buildTtsRequest(text, s) { @@ -9200,13 +9447,14 @@ async function _ttsViaProxy(req) { async function speakCookingStep(text) { if (!text) return; const s = getSettings(); - if (!s.tts_enabled) return; + // Use custom TTS endpoint only when explicitly configured; otherwise always use browser TTS. + // Do NOT gate on s.tts_enabled — the _cookingTTS toggle in cooking mode is the only gate. try { - if ((s.tts_engine || 'browser') === 'browser') { - _speakBrowser(text); - } else { + if (s.tts_engine === 'custom' && s.tts_url) { const req = _buildTtsRequest(text, s); await _ttsViaProxy(req); + } else { + _speakBrowser(text); } } catch(e) { /* silent — TTS is non-critical */ } } @@ -9235,11 +9483,26 @@ function onTtsEngineChange(engine) { /** Populate voice selector from Web Speech API. Called on settings load and on voiceschanged. */ function _initBrowserTtsVoices(selectedVoice) { const sel = document.getElementById('setting-tts-voice'); - if (!sel || !window.speechSynthesis) return; + if (!sel) return; + + // Inside the EverShelf Kiosk Android app the native TTS bridge handles + // speech — no Web Speech API voice list needed. + if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') { + sel.innerHTML = ''; + return; + } + + if (!window.speechSynthesis) { + sel.innerHTML = ''; + return; + } + + // Reset to loading state each time (settings page may be re-opened) + sel.innerHTML = ''; const populate = () => { const voices = window.speechSynthesis.getVoices(); - if (!voices.length) return; + if (!voices.length) return false; // Italian voices first, then others const it = voices.filter(v => v.lang.startsWith('it')); const others = voices.filter(v => !v.lang.startsWith('it')); @@ -9247,34 +9510,62 @@ function _initBrowserTtsVoices(selectedVoice) { sel.innerHTML = sorted.map(v => `` ).join(''); - // Auto-select Paola if no preference and it exists + // Auto-select first Italian voice if no preference set if (!selectedVoice) { const paola = sorted.find(v => v.name === 'Paola'); const firstIt = sorted.find(v => v.lang.startsWith('it')); if (paola) sel.value = paola.name; else if (firstIt) sel.value = firstIt.name; } + return true; }; - populate(); - if (window.speechSynthesis.onvoiceschanged !== undefined) { - window.speechSynthesis.onvoiceschanged = populate; - } + // Try immediately (voices already cached from previous call) + if (populate()) return; + + // onvoiceschanged fires in Firefox / some Chrome versions + window.speechSynthesis.onvoiceschanged = () => { populate(); }; + + // Polling fallback: Chrome/WebView loads voices async (up to ~3s on desktop, longer on Android) + let tries = 0; + const interval = setInterval(() => { + tries++; + if (populate()) { + clearInterval(interval); + } else if (tries >= 50) { // 50 × 200ms = 10s + clearInterval(interval); + if (!window.speechSynthesis.getVoices().length) { + sel.innerHTML = ''; + } + } + }, 200); } -/** Speak text using the browser Web Speech API (offline). */ +/** Speak text using the browser Web Speech API (offline). + * When running inside the EverShelf Kiosk Android app the native TTS bridge + * is preferred — it bypasses Web Speech API voice limitations on Android. */ function _speakBrowser(text) { + const s = getSettings(); + const rate = parseFloat(s.tts_rate) || 1; + const pitch = parseFloat(s.tts_pitch) || 1; + + // ── Native Android TTS bridge (kiosk WebView) ────────────────────── + if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') { + try { _kioskBridge.speak(text, rate, pitch); } catch(_e) { /* silent */ } + return; + } + + // ── Web Speech API (desktop / mobile browser) ────────────────────── if (!window.speechSynthesis) return; window.speechSynthesis.cancel(); - const s = getSettings(); const utt = new SpeechSynthesisUtterance(text); - utt.rate = parseFloat(s.tts_rate) || 1; - utt.pitch = parseFloat(s.tts_pitch) || 1; + utt.rate = rate; + utt.pitch = pitch; const voices = window.speechSynthesis.getVoices(); const preferred = voices.find(v => v.name === s.tts_voice); if (preferred) { utt.voice = preferred; - utt.lang = preferred.lang; + utt.lang = preferred.lang; } else { utt.lang = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-US' : 'it-IT'; } @@ -9290,6 +9581,16 @@ async function testTTS() { } const engine = document.getElementById('setting-tts-engine')?.value || 'browser'; if (engine === 'browser') { + // Kiosk native TTS bridge takes priority over Web Speech API + if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') { + const s = getSettings(); + s.tts_rate = parseFloat(document.getElementById('setting-tts-rate')?.value) || 1; + s.tts_pitch = parseFloat(document.getElementById('setting-tts-pitch')?.value) || 1; + saveSettingsToStorage(s); + _speakBrowser('Test vocale EverShelf. La sintesi vocale funziona correttamente.'); + if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Riproduzione in corso — controlla l\'audio del dispositivo.'; } + return; + } if (!window.speechSynthesis) { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Web Speech API non supportata da questo browser.'; } return; @@ -9442,6 +9743,9 @@ function toggleCookingTimerById(id) { t.running = true; t.interval = setInterval(() => { t.seconds--; + if (t.seconds === 10 && _cookingTTS) { + speakCookingStep(t('cooking.timer_warning_tts').replace('{label}', t.label)); + } if (t.seconds === 0) _cookingTimerDoneById(id); _updateTimerCard(id); }, 1000); @@ -9461,8 +9765,8 @@ function resetCookingTimerById(id) { function _cookingTimerDoneById(id) { if (navigator.vibrate) navigator.vibrate([300, 100, 300, 100, 300]); - const t = _cookingTimers.find(t => t.id === id); - if (_cookingTTS && t) speakCookingStep(`Timer ${t.label} scaduto!`); + const timer = _cookingTimers.find(ti => ti.id === id); + if (_cookingTTS && timer) speakCookingStep(t('cooking.timer_expired_tts').replace('{label}', timer.label)); } function _updateTimerCard(id) { @@ -9558,13 +9862,21 @@ function navigateCookingStep(delta) { const next = _cookingStep + delta; if (next < 0) return; if (next >= total) { - // All steps done: mark all visited, close overlay + // All steps done: mark all visited, announce completion, then close overlay for (let i = 0; i < total; i++) _cookingVisited.add(i); + if (_cookingTTS) { + const doneText = t('cooking.recipe_done_tts').replace('{title}', _cookingRecipe.title || ''); + speakCookingStep(doneText); + } closeCookingMode(); return; } _cookingStep = next; renderCookingStep(); + if (_cookingTTS) { + const text = ((_cookingRecipe.steps || [])[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, ''); + speakCookingStep(text); + } } function cookingUseIngredient(idx, productId, location, qtyNumber, btn) { @@ -9688,7 +10000,7 @@ async function generateRecipe() { const mealPlanType = mealPlanChipActive && (meal === 'pranzo' || meal === 'cena') ? (getTodayMealPlanType(meal) || null) : null; - + // Gather active options from checkboxes const options = []; const optMap = { @@ -9707,10 +10019,11 @@ async function generateRecipe() { document.getElementById('recipe-ask').style.display = 'none'; document.getElementById('recipe-loading').style.display = ''; document.getElementById('recipe-result').style.display = 'none'; + const loadingMsg = document.getElementById('recipe-loading-msg'); try { - const result = await api('generate_recipe', {}, 'POST', { - meal, + const payload = { + meal, persons, sub_type: MEAL_SUB_TYPES[meal] ? getSelectedSubType() : '', options, @@ -9720,34 +10033,74 @@ async function generateRecipe() { meal_plan_type: mealPlanType, variation: _recipeVariationCount[meal] || 0, rejected_ingredients: _rejectedRecipeIngredients, + }; + + const response = await fetch('api/index.php?action=generate_recipe_stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) }); - if (!result.success) { + if (!response.ok) { + const data = await response.json().catch(() => ({})); document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-ask').style.display = ''; - if (result.error === 'no_api_key') { + if (data.error === 'no_api_key') { showToast('⚠️ Chiave API Gemini non configurata', 'warning'); } else { - const detail = result.detail ? ` (${result.detail})` : ''; - showToast((result.error || 'Errore nella generazione') + detail, 'error'); + showToast(data.error || t('error.connection'), 'error'); } return; } - const r = result.recipe; - renderRecipe(r); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let recipe = null; + let errorEvent = null; - // Track title client-side immediately (before DB save completes) - if (r.title) _generatedTodayTitles.push(r.title); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const event = JSON.parse(line.slice(6)); + if (event.type === 'status' && loadingMsg) { + loadingMsg.textContent = event.message; + } else if (event.type === 'recipe') { + recipe = event.recipe; + } else if (event.type === 'error') { + errorEvent = event; + } + } catch (_) { /* ignore malformed SSE lines */ } + } + } - // Save to archive - await saveRecipeToArchive(r); - - // Cache the recipe for this meal type (in-memory only) - _cachedRecipe = { meal, recipe: r }; - - document.getElementById('recipe-loading').style.display = 'none'; - document.getElementById('recipe-result').style.display = ''; + if (recipe) { + renderRecipe(recipe); + if (recipe.title) _generatedTodayTitles.push(recipe.title); + await saveRecipeToArchive(recipe); + _cachedRecipe = { meal, recipe }; + document.getElementById('recipe-loading').style.display = 'none'; + document.getElementById('recipe-result').style.display = ''; + } else { + document.getElementById('recipe-loading').style.display = 'none'; + document.getElementById('recipe-ask').style.display = ''; + if (errorEvent) { + if (errorEvent.error === 'no_api_key') { + showToast('⚠️ Chiave API Gemini non configurata', 'warning'); + } else { + const detail = errorEvent.detail ? ` (${errorEvent.detail})` : ''; + showToast((errorEvent.error || 'Errore nella generazione') + detail, 'error'); + } + } else { + showToast(t('error.connection'), 'error'); + } + } } catch (err) { console.error('Recipe error:', err); @@ -9839,7 +10192,7 @@ async function sendChatMessage() { } } catch(err) { typingEl.remove(); - appendChatBubble('gemini', '⚠️ Errore di connessione'); + appendChatBubble('gemini', '⚠️ ' + t('error.connection')); } btn.disabled = false; @@ -9912,17 +10265,17 @@ function clearChat() { container.innerHTML = `
-

Ciao! Sono il tuo assistente cucina

-

Chiedimi di prepararti un succo, uno spuntino, un piatto veloce... Conosco la tua dispensa, i tuoi elettrodomestici e le tue preferenze!

+

${t('chat.welcome')}

+

${t('chat.welcome_desc')}

- - - - + + + +
`; - showToast('Chat cancellata', 'success'); + showToast(t('chat.cleared'), 'success'); } function saveChatHistory() { @@ -9974,8 +10327,9 @@ function activateScreensaver() { function updateScreensaverClock() { const now = new Date(); - const time = now.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); - const date = now.toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' }); + const _ssLocale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT'; + const time = now.toLocaleTimeString(_ssLocale, { hour: '2-digit', minute: '2-digit' }); + const date = now.toLocaleDateString(_ssLocale, { weekday: 'long', day: 'numeric', month: 'long' }); const el = document.getElementById('screensaver-clock'); if (el) el.innerHTML = `${time}
${date}
`; updateScreensaverMealPlan(); @@ -10452,7 +10806,7 @@ function spesaModeAfterAdd() { function _spesaBannerStat() { const n = _spesaSession.length; - if (n === 0) return '🛒 Nessun prodotto ancora'; + if (n === 0) return t('shopping.session_empty'); const cats = {}; _spesaSession.forEach(p => { const c = p.category || 'altro'; cats[c] = (cats[c]||0)+1; }); const topCat = Object.entries(cats).sort((a,b)=>b[1]-a[1])[0]; diff --git a/data/anomaly_dismissed.json b/data/anomaly_dismissed.json new file mode 100644 index 0000000..2d2ff98 --- /dev/null +++ b/data/anomaly_dismissed.json @@ -0,0 +1 @@ +{"a_32_572":1776776330,"a_17_171":1776776404,"a_25_-777":1776776427,"a_7_-279":1776776434,"a_168_253":1777223925} \ No newline at end of file diff --git a/data/bring_catalog.json b/data/bring_catalog.json new file mode 100644 index 0000000..0beb04a --- /dev/null +++ b/data/bring_catalog.json @@ -0,0 +1,645 @@ +{ + "de2it": { + "Getreideriegel": "Barretta ai cereali", + "Glasreiniger": "Pulizia vetri", + "Gartenwerkzeug": "Atrezzi da giardino", + "Getränke": "Bibite", + "Hackfleisch": "Carne macinata", + "Baumarkt & Garten": "Fai da te & Giardino", + "Kekse": "Biscotti", + "Salami": "Salame", + "Lippenpomade": "Burrocacao", + "Putzmittel": "Detergente", + "Samen": "Sementi", + "Wassermelone": "Anguria", + "Schokolade": "Cioccolato", + "Fertig- & Tiefkühlprodukte": "Piatti Pronti & Surgelati", + "Käse": "Formaggio", + "Giesskanne": "Annaffiatoio", + "Bratwurst": "Wurstel", + "Fenchel": "Finocchio", + "Fruchtsaft": "Succo di frutta", + "Grissini": "Grissini", + "Brokkoli": "Broccoli", + "Eistee": "Tè freddo", + "Haarspray": "Spray", + "Pflaumen": "Susina", + "Pommes Chips": "Patatine", + "Schweinefleisch": "Carne di maiale", + "Backpapier": "Carta da forno", + "Brot": "Pane", + "Orangensaft": "Succo d'arancia", + "Geschirrsalz": "Sale Lavastoviglie", + "Gipfeli": "Cornetti", + "Birnen": "Pere", + "Eier": "Uova", + "Makeup Entferner": "Struccante", + "Steinpilze": "Porcini", + "Kartoffeln": "Patate", + "Rasierklingen": "Ricambi rasoio", + "Gemüse": "Verdure", + "Kaffee": "Caffè", + "Frischkäse": "Formaggio cremoso", + "Zutaten & Gewürze": "Ingredienti & Spezie", + "Öl": "Olio", + "Trauben": "Uva", + "Salz": "Sale", + "Balsamico": "Aceto Balsamico", + "Fisch": "Pesce", + "Radicchio": "Radicchio", + "Geschenk": "Regalo", + "Blumen": "Fiori", + "Limonade": "Bibite", + "Schwamm": "Spugna", + "Limette": "Limone verde", + "Aubergine": "Melanzana", + "Schinken": "Prosciutto cotto", + "Zucchetti": "Zucchine", + "Rum": "Rum", + "Frühlingszwiebeln": "Cipollotti", + "Spargel": "Asparagi", + "Sonnencreme": "Crema da sole", + "Gnocchi": "Gnocchi", + "Handcreme": "Crema mani", + "Schnittlauch": "Erba Cipollina", + "Snacks & Süsswaren": "Snack & Dolci", + "Pelati": "Pelati", + "Fischstäbli": "Bastoncini di pesce", + "Margarine": "Margarina", + "Fleisch & Fisch": "Carne & Pesce", + "Zigaretten": "Sigarette", + "Oregano": "Origano", + "Basmatireis": "Riso Basmati", + "Zahnseide": "Filo interdentale", + "Tofu": "Tofu", + "Energy Drink": "Energy Drink", + "Peperoni": "Peperoni", + "Sirup": "Sciroppo", + "Feigen": "Fichi", + "Haselnüsse": "Nocciole", + "Mehl": "Farina", + "Haferflocken": "Avena", + "Apfelmus": "Composta di mele", + "Reis": "Riso", + "Mascarpone": "Mascarpone", + "Rasenmäher": "Tosaerba", + "Schnitzel": "Scaloppine", + "Grill": "Griglia", + "Ketchup": "Ketchup", + "Lachs": "Salmone", + "Zwiebeln": "Cipolle", + "Beeren": "Bacche", + "Pflaster": "Cerotti", + "Fischfutter": "Mangime per pesci", + "Kerzen": "Candele", + "Früchte": "Frutta", + "Kalbfleisch": "Carne di vitello", + "Rasierschaum": "Schiuma da barba", + "Ingwer": "Zenzero", + "Pfirsich": "Pesca", + "Sauerrahm": "Panna Acidula", + "Früchte & Gemüse": "Frutta & Verdura", + "Lasagne": "Lasagne", + "Pinsel": "Pennello", + "Hefe": "Lievito", + "Kuchen": "Torta", + "Prosecco": "Prosecco", + "Tampons": "Assorbenti", + "Thunfisch": "Pesce Tonno", + "Zucker": "Zucchero", + "Piadina": "Piadina", + "Pouletbrüstli": "Petto di pollo", + "Kastanien": "Castagne", + "Blumenkohl": "Cavolfiore", + "Salat": "Insalata", + "Fleisch": "Carne", + "Corn Flakes": "Cereali colazione", + "Merendina": "Merendina", + "Knoblauch": "Aglio", + "Karotten": "Carote", + "Toast": "Toast", + "Waschmittel": "Detersivo lavatrice", + "Salatsauce": "Condimento insalata", + "Hundefutter": "Cibo per cani", + "Vanillezucker": "Zucchero vanigliato", + "Mundspülung": "Collutorio", + "Babynahrung": "Alimenti Bimbi", + "Windeln": "Pannolini", + "Kondome": "Preservativo", + "Couscous": "Couscous", + "Geschirrglanz": "Brillantante", + "Aprikosen": "Albicocche", + "Himbeeren": "Lamponi", + "Oliven": "Olive", + "Lebkuchen": "Panpepato", + "Getreideprodukte": "Pasta, Riso & Cereali", + "Kürbis": "Zucca", + "Tonic Water": "Tonic", + "Nektarine": "Pesche noci", + "Penne": "Penne", + "Shampoo": "Shampoo", + "Whisky": "Whisky", + "Datteln": "Datteri", + "Kakao": "Cacao", + "Olivenöl": "Olio d'oliva", + "Bohnen": "Fagioli", + "Pizza": "Pizza", + "Kiwi": "Kiwi", + "Poulet": "Pollo", + "Wasser": "Acqua", + "Pasta": "Pasta", + "Milch": "Latte", + "Kirschen": "Ciliegie", + "Mandeln": "Mandorle", + "Milch & Käse": "Latte & Formaggi", + "Kichererbsen": "Ceci", + "Kosmetiktücher": "Kleenex", + "Kaugummi": "Gomma da masticare", + "Gesichtscreme": "Crema viso", + "getrocknete Tomaten": "Pomodori secchi", + "Champignons": "Champignons", + "Cola Light": "Cola Light", + "Orange": "Arance", + "Alufolie": "Foglio di alluminio", + "Melone": "Melone", + "Bananen": "Banane", + "Zahnbürsten": "Spazzolino", + "Zimt": "Cannella", + "Äpfel": "Mele", + "Cola": "Cola", + "Bouillon": "Brodo", + "Salbei": "Salvia", + "Soyasauce": "Salsa di soia", + "Rohschinken": "Prosciutto crudo", + "Reibkäse": "Formaggio grattugiato", + "Aufschnitt": "Affettato", + "Geschirrtabs": "Past. Lavastoviglie", + "Sonnenschirm": "Ombrellone", + "Bresaola": "Bresaola", + "Mineralwasser": "Acqua minerale", + "Taschentücher": "Fazzoletti", + "Haushalt & Gesundheit": "Casa & Igiene", + "Feuchttücher": "Salviette", + "Erbsen": "Piselli", + "Parmesan": "Parmigiano", + "Nougatcreme": "Crema gianduia", + "Speck": "Pancetta", + "Tierbedarf": "Animali", + "Avocado": "Avocado", + "Paprikapulver": "Paprica", + "Abfallsäcke": "Sacchi della spazzatura", + "Essig": "Aceto", + "Dünger": "Concime", + "Pilze": "Funghi", + "Batterien": "Batterie", + "Tomatensauce": "Sugo di pomodoro", + "Rucola": "Rucola", + "Bier": "Birra", + "Blumenerde": "Terriccio", + "Rhabarber": "Rabarbaro", + "Artischocken": "Carciofi", + "Rosmarin": "Rosmarino", + "Thon": "Tonno", + "Linsenmittel": "Soluzione lenti", + "Nagellackentferner": "Acetone", + "Bodylotion": "Crema corpo", + "Apfelsaft": "Succo di mela", + "Guetzli": "Biscotti di Natale", + "Tomatenmark": "Concentrato Pomodoro", + "Gurke": "Cetriolo", + "Holzkohle": "Carbonella", + "Basilikum": "Basilico", + "Joghurt": "Yogurt", + "Getränke & Tabak": "Bevande & Tabacco", + "Pop Corn": "Popcorn", + "Weichspüler": "Ammorbidente", + "Butter": "Burro", + "Rotwein": "Vino Rosso", + "Frankfurter": "Luganega", + "Schnaps": "Grappa", + "Tomaten": "Pomodori", + "Ricotta": "Ricotta", + "Watterondellen": "Dischetti di cotone", + "Erdbeeren": "Fragole", + "Vogelfutter": "Cibo per uccelli", + "Thymian": "Timo", + "Puderzucker": "Zucchero a velo", + "Kräuterbutter": "Burro alle erbe", + "Kaki": "Cachi", + "Erdnüsse": "Arachidi", + "Pfefferkörner": "Grani di pepe", + "Schrauben": "Viti", + "Sardellen": "Acciughe", + "Rindfleisch": "Carne di manzo", + "Conditioner": "Balsamo", + "Pizzateig": "Pasta per pizza", + "Zitrone": "Limone", + "Nägel": "Chiodi", + "Peperoncini": "Peperoncini", + "Senf": "Senape", + "Brötchen": "Panini", + "Baumnüsse": "Noci", + "Nudeln": "Tagliatelle", + "Wurst": "Salsiccia", + "Pudding": "Pudding", + "Griess": "Semolino", + "Mandarinen": "Mandarini", + "Weisswein": "Vino bianco", + "Blätterteig": "Pasta Sfoglia", + "Cherrytomaten": "Pomodorini", + "Pfefferminze": "Menta", + "Katzenstreu": "Sabbia gatti", + "Zwetschgen": "Prugne", + "Brombeeren": "More", + "Gin": "Gin", + "Vodka": "Vodka", + "Honig": "Miele", + "WC-Papier": "Carta igienica", + "Brot & Gebäck": "Panetteria", + "Paniermehl": "Pangrattato", + "Abwaschmittel": "Detersivo Piatti", + "Rahm": "Panna", + "Mayonnaise": "Maionese", + "Spülmittel": "Detersivo", + "Sellerie": "Sedano", + "Lauch": "Porro", + "Rindsgeschnetzeltes": "Sminuzzato manzo", + "WC-Reiniger": "Detergente per WC", + "Baguette": "Baguette", + "Konfitüre": "Marmellata", + "Schmerzmittel": "Analgesico", + "Badreiniger": "Pulizia bagno", + "Mango": "Mango", + "Mozzarella": "Mozzarella", + "Ananas": "Ananas", + "Propangas": "Propano", + "Bratensauce": "Salsa per arrosto", + "Orecchiette": "Orecchiette", + "Lamm": "Agnello", + "Frischhaltefolie": "Pellicole", + "Zahnpasta": "Dentifricio", + "Spaghetti": "Spaghetti", + "Haargel": "Gel Styling", + "Snacks": "Snack", + "Petersilie": "Prezzemolo", + "Grapefruit": "Pompelmo", + "Grana Padano": "Grana Padano", + "Servietten": "Tovaglioli", + "Töpfe": "Vasi", + "Linsen": "Lenticchie", + "Duschmittel": "Crema doccia", + "Gorgonzola": "Gorgonzola", + "Spinat": "Spinaci", + "Backpulver": "Bicarbonato", + "Risottoreis": "Risotto", + "Rasierer": "Rasoio", + "Pommes Frites": "Patate fritte", + "Deo": "Deodorante", + "Pflanzen": "Piante", + "Katzenfutter": "Cibo per gatti", + "Geschenkpapier": "Carta da regalo", + "Tee": "Tè", + "Wattestäbchen": "Bastoncini cotonati", + "Kräuter": "Erbe", + "Seife": "Sapone", + "Glacé": "Gelato", + "Mais": "Mais", + "Haushaltspapier": "Carta domestica", + "Polenta": "Polenta", + "Eigene Artikel": "Tuoi articoli", + "Zuletzt verwendet": "Utilizzato per ultimo" + }, + "it2de": { + "barretta ai cereali": "Getreideriegel", + "pulizia vetri": "Glasreiniger", + "atrezzi da giardino": "Gartenwerkzeug", + "bibite": "Limonade", + "carne macinata": "Hackfleisch", + "fai da te & giardino": "Baumarkt & Garten", + "biscotti": "Kekse", + "salame": "Salami", + "burrocacao": "Lippenpomade", + "detergente": "Putzmittel", + "sementi": "Samen", + "anguria": "Wassermelone", + "cioccolato": "Schokolade", + "piatti pronti & surgelati": "Fertig- & Tiefkühlprodukte", + "formaggio": "Käse", + "annaffiatoio": "Giesskanne", + "wurstel": "Bratwurst", + "finocchio": "Fenchel", + "succo di frutta": "Fruchtsaft", + "grissini": "Grissini", + "broccoli": "Brokkoli", + "tè freddo": "Eistee", + "spray": "Haarspray", + "susina": "Pflaumen", + "patatine": "Pommes Chips", + "carne di maiale": "Schweinefleisch", + "carta da forno": "Backpapier", + "pane": "Brot", + "succo d'arancia": "Orangensaft", + "sale lavastoviglie": "Geschirrsalz", + "cornetti": "Gipfeli", + "pere": "Birnen", + "uova": "Eier", + "struccante": "Makeup Entferner", + "porcini": "Steinpilze", + "patate": "Kartoffeln", + "ricambi rasoio": "Rasierklingen", + "verdure": "Gemüse", + "caffè": "Kaffee", + "formaggio cremoso": "Frischkäse", + "ingredienti & spezie": "Zutaten & Gewürze", + "olio": "Öl", + "uva": "Trauben", + "sale": "Salz", + "aceto balsamico": "Balsamico", + "pesce": "Fisch", + "radicchio": "Radicchio", + "regalo": "Geschenk", + "fiori": "Blumen", + "spugna": "Schwamm", + "limone verde": "Limette", + "melanzana": "Aubergine", + "prosciutto cotto": "Schinken", + "zucchine": "Zucchetti", + "rum": "Rum", + "cipollotti": "Frühlingszwiebeln", + "asparagi": "Spargel", + "crema da sole": "Sonnencreme", + "gnocchi": "Gnocchi", + "crema mani": "Handcreme", + "erba cipollina": "Schnittlauch", + "snack & dolci": "Snacks & Süsswaren", + "pelati": "Pelati", + "bastoncini di pesce": "Fischstäbli", + "margarina": "Margarine", + "carne & pesce": "Fleisch & Fisch", + "sigarette": "Zigaretten", + "origano": "Oregano", + "riso basmati": "Basmatireis", + "filo interdentale": "Zahnseide", + "tofu": "Tofu", + "energy drink": "Energy Drink", + "peperoni": "Peperoni", + "sciroppo": "Sirup", + "fichi": "Feigen", + "nocciole": "Haselnüsse", + "farina": "Mehl", + "avena": "Haferflocken", + "composta di mele": "Apfelmus", + "riso": "Reis", + "mascarpone": "Mascarpone", + "tosaerba": "Rasenmäher", + "scaloppine": "Schnitzel", + "griglia": "Grill", + "ketchup": "Ketchup", + "salmone": "Lachs", + "cipolle": "Zwiebeln", + "bacche": "Beeren", + "cerotti": "Pflaster", + "mangime per pesci": "Fischfutter", + "candele": "Kerzen", + "frutta": "Früchte", + "carne di vitello": "Kalbfleisch", + "schiuma da barba": "Rasierschaum", + "zenzero": "Ingwer", + "pesca": "Pfirsich", + "panna acidula": "Sauerrahm", + "frutta & verdura": "Früchte & Gemüse", + "lasagne": "Lasagne", + "pennello": "Pinsel", + "lievito": "Hefe", + "torta": "Kuchen", + "prosecco": "Prosecco", + "assorbenti": "Tampons", + "pesce tonno": "Thunfisch", + "zucchero": "Zucker", + "piadina": "Piadina", + "petto di pollo": "Pouletbrüstli", + "castagne": "Kastanien", + "cavolfiore": "Blumenkohl", + "insalata": "Salat", + "carne": "Fleisch", + "cereali colazione": "Corn Flakes", + "merendina": "Merendina", + "aglio": "Knoblauch", + "carote": "Karotten", + "toast": "Toast", + "detersivo lavatrice": "Waschmittel", + "condimento insalata": "Salatsauce", + "cibo per cani": "Hundefutter", + "zucchero vanigliato": "Vanillezucker", + "collutorio": "Mundspülung", + "alimenti bimbi": "Babynahrung", + "pannolini": "Windeln", + "preservativo": "Kondome", + "couscous": "Couscous", + "brillantante": "Geschirrglanz", + "albicocche": "Aprikosen", + "lamponi": "Himbeeren", + "olive": "Oliven", + "panpepato": "Lebkuchen", + "pasta, riso & cereali": "Getreideprodukte", + "zucca": "Kürbis", + "tonic": "Tonic Water", + "pesche noci": "Nektarine", + "penne": "Penne", + "shampoo": "Shampoo", + "whisky": "Whisky", + "datteri": "Datteln", + "cacao": "Kakao", + "olio d'oliva": "Olivenöl", + "fagioli": "Bohnen", + "pizza": "Pizza", + "kiwi": "Kiwi", + "pollo": "Poulet", + "acqua": "Wasser", + "pasta": "Pasta", + "latte": "Milch", + "ciliegie": "Kirschen", + "mandorle": "Mandeln", + "latte & formaggi": "Milch & Käse", + "ceci": "Kichererbsen", + "kleenex": "Kosmetiktücher", + "gomma da masticare": "Kaugummi", + "crema viso": "Gesichtscreme", + "pomodori secchi": "getrocknete Tomaten", + "champignons": "Champignons", + "cola light": "Cola Light", + "arance": "Orange", + "foglio di alluminio": "Alufolie", + "melone": "Melone", + "banane": "Bananen", + "spazzolino": "Zahnbürsten", + "cannella": "Zimt", + "mele": "Äpfel", + "cola": "Cola", + "brodo": "Bouillon", + "salvia": "Salbei", + "salsa di soia": "Soyasauce", + "prosciutto crudo": "Rohschinken", + "formaggio grattugiato": "Reibkäse", + "affettato": "Aufschnitt", + "past. lavastoviglie": "Geschirrtabs", + "ombrellone": "Sonnenschirm", + "bresaola": "Bresaola", + "acqua minerale": "Mineralwasser", + "fazzoletti": "Taschentücher", + "casa & igiene": "Haushalt & Gesundheit", + "salviette": "Feuchttücher", + "piselli": "Erbsen", + "parmigiano": "Parmesan", + "crema gianduia": "Nougatcreme", + "pancetta": "Speck", + "animali": "Tierbedarf", + "avocado": "Avocado", + "paprica": "Paprikapulver", + "sacchi della spazzatura": "Abfallsäcke", + "aceto": "Essig", + "concime": "Dünger", + "funghi": "Pilze", + "batterie": "Batterien", + "sugo di pomodoro": "Tomatensauce", + "rucola": "Rucola", + "birra": "Bier", + "terriccio": "Blumenerde", + "rabarbaro": "Rhabarber", + "carciofi": "Artischocken", + "rosmarino": "Rosmarin", + "tonno": "Thon", + "soluzione lenti": "Linsenmittel", + "acetone": "Nagellackentferner", + "crema corpo": "Bodylotion", + "succo di mela": "Apfelsaft", + "biscotti di natale": "Guetzli", + "concentrato pomodoro": "Tomatenmark", + "cetriolo": "Gurke", + "carbonella": "Holzkohle", + "basilico": "Basilikum", + "yogurt": "Joghurt", + "bevande & tabacco": "Getränke & Tabak", + "popcorn": "Pop Corn", + "ammorbidente": "Weichspüler", + "burro": "Butter", + "vino rosso": "Rotwein", + "luganega": "Frankfurter", + "grappa": "Schnaps", + "pomodori": "Tomaten", + "ricotta": "Ricotta", + "dischetti di cotone": "Watterondellen", + "fragole": "Erdbeeren", + "cibo per uccelli": "Vogelfutter", + "timo": "Thymian", + "zucchero a velo": "Puderzucker", + "burro alle erbe": "Kräuterbutter", + "cachi": "Kaki", + "arachidi": "Erdnüsse", + "grani di pepe": "Pfefferkörner", + "viti": "Schrauben", + "acciughe": "Sardellen", + "carne di manzo": "Rindfleisch", + "balsamo": "Conditioner", + "pasta per pizza": "Pizzateig", + "limone": "Zitrone", + "chiodi": "Nägel", + "peperoncini": "Peperoncini", + "senape": "Senf", + "panini": "Brötchen", + "noci": "Baumnüsse", + "tagliatelle": "Nudeln", + "salsiccia": "Wurst", + "pudding": "Pudding", + "semolino": "Griess", + "mandarini": "Mandarinen", + "vino bianco": "Weisswein", + "pasta sfoglia": "Blätterteig", + "pomodorini": "Cherrytomaten", + "menta": "Pfefferminze", + "sabbia gatti": "Katzenstreu", + "prugne": "Zwetschgen", + "more": "Brombeeren", + "gin": "Gin", + "vodka": "Vodka", + "miele": "Honig", + "carta igienica": "WC-Papier", + "panetteria": "Brot & Gebäck", + "pangrattato": "Paniermehl", + "detersivo piatti": "Abwaschmittel", + "panna": "Rahm", + "maionese": "Mayonnaise", + "detersivo": "Spülmittel", + "sedano": "Sellerie", + "porro": "Lauch", + "sminuzzato manzo": "Rindsgeschnetzeltes", + "detergente per wc": "WC-Reiniger", + "baguette": "Baguette", + "marmellata": "Konfitüre", + "analgesico": "Schmerzmittel", + "pulizia bagno": "Badreiniger", + "mango": "Mango", + "mozzarella": "Mozzarella", + "ananas": "Ananas", + "propano": "Propangas", + "salsa per arrosto": "Bratensauce", + "orecchiette": "Orecchiette", + "agnello": "Lamm", + "pellicole": "Frischhaltefolie", + "dentifricio": "Zahnpasta", + "spaghetti": "Spaghetti", + "gel styling": "Haargel", + "snack": "Snacks", + "prezzemolo": "Petersilie", + "pompelmo": "Grapefruit", + "grana padano": "Grana Padano", + "tovaglioli": "Servietten", + "vasi": "Töpfe", + "lenticchie": "Linsen", + "crema doccia": "Duschmittel", + "gorgonzola": "Gorgonzola", + "spinaci": "Spinat", + "bicarbonato": "Backpulver", + "risotto": "Risottoreis", + "rasoio": "Rasierer", + "patate fritte": "Pommes Frites", + "deodorante": "Deo", + "piante": "Pflanzen", + "cibo per gatti": "Katzenfutter", + "carta da regalo": "Geschenkpapier", + "tè": "Tee", + "bastoncini cotonati": "Wattestäbchen", + "erbe": "Kräuter", + "sapone": "Seife", + "gelato": "Glacé", + "mais": "Mais", + "carta domestica": "Haushaltspapier", + "polenta": "Polenta", + "tuoi articoli": "Eigene Artikel", + "utilizzato per ultimo": "Zuletzt verwendet", + "aroma": "Zutaten & Gewürze", + "ingredienti spezie": "Zutaten & Gewürze", + "bevande": "Getränke & Tabak", + "camomilla": "Tee", + "cioccolata calda": "Kakao", + "cipolla": "Zwiebeln", + "cracker": "Snacks & Süsswaren", + "farina integrale": "Mehl", + "fette biscottate": "Toast", + "filetto": "Fleisch", + "liquore": "Getränke & Tabak", + "muesli": "Corn Flakes", + "panna da cucina": "Rahm", + "passata": "Pelati", + "piatti pronti": "Fertig- & Tiefkühlprodukte", + "polpa di pomodoro": "Pelati", + "purè": "Fertig- & Tiefkühlprodukte", + "salsa": "Zutaten & Gewürze", + "sfornatini": "Fertig- & Tiefkühlprodukte", + "snack dolci": "Snacks & Süsswaren", + "succo": "Fruchtsaft", + "taralli": "Snacks & Süsswaren", + "vino": "Rotwein", + "zucchero di canna": "Zucker" + } +} \ No newline at end of file diff --git a/data/shopping_name_cache.json b/data/shopping_name_cache.json new file mode 100644 index 0000000..8a14bbc --- /dev/null +++ b/data/shopping_name_cache.json @@ -0,0 +1,20 @@ +{ + "dc1bb00e006a5ed073aad9b0ca2f1601": "Toast", + "f03b656f4cfaa9d633fc155cdafcb83b": "Sale", + "fa1266e5e6bb32602e08aaf9434ec9ad": "Patate", + "ca2da3ad2a7b42e717f766e06a83730e": "Verdure", + "ce8f4f54fc6ead0f0a8ce36503bba462": "Pasta", + "2ddb0faf33c4ceeed89fada2c7c2b9c5": "Ingredienti Spezie", + "0290647fcd95ec97f0d6666c46a72943": "Brodo", + "405ea6ec33d54042d046599650f422ea": "Succo", + "f624c420f14d8eff122c0bb395eb63da": "Snack Dolci", + "92751fbb97923590c402bc7810778b36": "Biscotti", + "8727f7abcb66764b5eb3d1f036bc18b8": "Tè", + "0eb53fe1a5d4d106eac47c8a81d1afe7": "Farina", + "0ebada5597d1d166d0ed8f49500bfeba": "Verdure", + "fe7456efb7e767a06e3af9f5ec7b3637": "Piatti Pronti", + "2a5d2289bb7bc306dd066dfaff7ef581": "Ingredienti Spezie", + "b630c06f2ac72a1e2ffbd57d327a3733": "Salsa", + "32a05ae91ccfa4d37be454836971436b": "Ingredienti", + "a21f0e7718c8f12166d864d0d05f60a0": "Salsa" +} \ No newline at end of file diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt index d6ccb1a..04d81c6 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -14,11 +14,13 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.speech.tts.TextToSpeech import android.view.View import android.view.WindowInsets import android.view.WindowInsetsController import android.view.WindowManager import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface import android.webkit.PermissionRequest import android.webkit.SslErrorHandler import android.webkit.ValueCallback @@ -39,6 +41,7 @@ import androidx.core.content.ContextCompat import com.google.android.material.button.MaterialButton import org.json.JSONObject import java.net.URL +import java.util.Locale import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.TrustManager @@ -49,6 +52,11 @@ class KioskActivity : AppCompatActivity() { private lateinit var prefs: SharedPreferences private var currentStep = 1 + // Native TTS engine (Android) — used by the JS bridge so the WebView + // doesn't depend on Web Speech API voices being installed. + private var tts: TextToSpeech? = null + private var ttsReady = false + // Views private lateinit var splashContainer: LinearLayout private lateinit var wizardContainer: ScrollView @@ -98,6 +106,19 @@ class KioskActivity : AppCompatActivity() { enableKioskLock() requestAllPermissions() + // Initialise native TTS engine so the JS bridge works even when + // Web Speech API voices are unavailable in the Android WebView. + tts = TextToSpeech(this) { status -> + if (status == TextToSpeech.SUCCESS) { + val it = tts?.setLanguage(Locale.ITALIAN) + if (it == TextToSpeech.LANG_MISSING_DATA || it == TextToSpeech.LANG_NOT_SUPPORTED) { + // Italian data missing — fall back to device default + tts?.language = Locale.getDefault() + } + ttsReady = true + } + } + // Show splash then proceed Handler(Looper.getMainLooper()).postDelayed({ splashContainer.visibility = View.GONE @@ -506,7 +527,7 @@ class KioskActivity : AppCompatActivity() { // Add JS interface ONCE before loading webView.addJavascriptInterface(object { - @android.webkit.JavascriptInterface + @JavascriptInterface fun exit() { runOnUiThread { disableKioskLock() @@ -514,13 +535,35 @@ class KioskActivity : AppCompatActivity() { finishAffinity() } } - @android.webkit.JavascriptInterface + @JavascriptInterface fun hardReload() { runOnUiThread { webView.clearCache(true) webView.reload() } } + /** + * Speak [text] via Android native TTS. + * Called by app.js when running inside the kiosk WebView so that + * speech synthesis works even without Web Speech API offline voices. + * [rate] and [pitch] are floats (default 1.0). + */ + @JavascriptInterface + fun speak(text: String, rate: Float, pitch: Float) { + val engine = tts ?: return + if (!ttsReady) return + engine.setSpeechRate(rate.coerceIn(0.1f, 4f)) + engine.setPitch(pitch.coerceIn(0.1f, 4f)) + engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts") + } + /** Cancel any ongoing speech. */ + @JavascriptInterface + fun stopSpeech() { + tts?.stop() + } + /** Returns "true" when the TTS engine is ready. */ + @JavascriptInterface + fun isTtsReady(): String = if (ttsReady) "true" else "false" }, "_kioskBridge") val url = prefs.getString(KEY_URL, "http://evershelf.local") ?: "http://evershelf.local" @@ -729,6 +772,13 @@ class KioskActivity : AppCompatActivity() { } } + override fun onDestroy() { + tts?.stop() + tts?.shutdown() + tts = null + super.onDestroy() + } + override fun onBackPressed() { if (webView.visibility == View.VISIBLE && webView.canGoBack()) { webView.goBack() diff --git a/evershelf-kiosk/gradle.properties b/evershelf-kiosk/gradle.properties index 646c51b..33a70d7 100644 --- a/evershelf-kiosk/gradle.properties +++ b/evershelf-kiosk/gradle.properties @@ -1,2 +1,3 @@ android.useAndroidX=true android.enableJetifier=true +# Build trigger: TTS bridge fix (95389eb) diff --git a/index.html b/index.html index ad57a5e..1ab8625 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@ EverShelf - + @@ -930,10 +930,13 @@
- -

Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano).

+
+ + +
+

Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.

@@ -1195,7 +1198,7 @@
- + diff --git a/manifest.json b/manifest.json index 87f1761..f15dca5 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "name": "EverShelf", "short_name": "EverShelf", "description": "Gestione completa della dispensa di casa con scansione barcode", - "version": "1.4.0", + "version": "1.5.0", "start_url": "/evershelf/", "display": "standalone", "background_color": "#f0f4e8", diff --git a/test_recipe_stream.php b/test_recipe_stream.php new file mode 100644 index 0000000..c838b74 --- /dev/null +++ b/test_recipe_stream.php @@ -0,0 +1,208 @@ +query("SELECT count(*) FROM inventory WHERE quantity > 0")->fetchColumn(); +if ($itemCount > 0) { + pass("Inventory: $itemCount items"); +} else { + fail("Inventory is empty — cannot generate recipe"); + exit(1); +} + +// ── TEST 3: Prompt token estimation (B) ───────────────────────────────────── +// Simulate building the ingredient list with new limits +$stmt = $db->query(" + SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at, + CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left + FROM inventory i + JOIN products p ON p.id = i.product_id + WHERE i.quantity > 0 ORDER BY days_left ASC +"); +$items = $stmt->fetchAll(PDO::FETCH_ASSOC); + +$getItemPriority = function($item): int { + $daysLeft = floatval($item['days_left']); + $isOpen = !empty($item['opened_at']) || (floatval($item['quantity']) > 0 && floatval($item['quantity']) < 1 && $item['unit'] === 'conf'); + if (!empty($item['expiry_date']) && $daysLeft < 0) return 1; + if (!empty($item['expiry_date']) && $daysLeft <= 3) return 2; + if (!empty($item['expiry_date']) && $daysLeft <= 7) return 3; + if (!empty($item['expiry_date'])) return 4; + if ($isOpen) return 5; + return 6; +}; + +$staplePatterns = '/\b(sale|pepe|olio d.oliva|olio di semi|olio extra|acqua|aceto balsamico|aceto di|sel marin)\b/i'; +$priorityGroups = []; +foreach ($items as $item) { + $group = $getItemPriority($item); + if ($group >= 5 && preg_match($staplePatterns, $item['name'])) continue; + $line = "- {$item['name']}: {$item['quantity']} {$item['unit']}"; + $priorityGroups[$group][] = $line; +} + +// OLD limits +$oldSections = []; +foreach ([1=>null,2=>null,3=>null,4=>40,5=>null,6=>20] as $g => $limit) { + if (empty($priorityGroups[$g])) continue; + $gi = $limit ? array_slice($priorityGroups[$g], 0, $limit) : $priorityGroups[$g]; + $oldSections[] = implode("\n", $gi); +} +$oldText = implode("\n", $oldSections); +$oldTokens = (int)(str_word_count($oldText) * 1.3); // rough estimate: words * 1.3 + +// NEW limits +$newSections = []; +foreach ([1=>null,2=>null,3=>null,4=>15,5=>null,6=>8] as $g => $limit) { + if (empty($priorityGroups[$g])) continue; + $gi = $limit ? array_slice($priorityGroups[$g], 0, $limit) : $priorityGroups[$g]; + $newSections[] = implode("\n", $gi); +} +$newText = implode("\n", $newSections); +$newTokens = (int)(str_word_count($newText) * 1.3); +$savings = $oldTokens > 0 ? round(($oldTokens - $newTokens) / $oldTokens * 100) : 0; + +info("Prompt ingredient tokens: OLD ~$oldTokens → NEW ~$newTokens (saved ~$savings%)"); +if ($savings >= 20) { + pass("Token reduction >= 20% (got $savings%)"); +} else { + fail("Token reduction too low ($savings%) — check group limits"); +} + +// ── TEST 4: Real SSE call via HTTP ─────────────────────────────────────────── +info("Calling generate_recipe_stream via HTTP (cena, pesce, 2 persone)..."); + +$postData = json_encode([ + 'meal' => 'cena', + 'persons' => 2, + 'sub_type' => '', + 'options' => [], + 'appliances' => [], + 'dietary_restrictions' => '', + 'today_recipes' => [], + 'meal_plan_type' => 'pesce', + 'variation' => 0, + 'rejected_ingredients' => [], +]); + +$startTime = microtime(true); +$ch = curl_init('https://localhost/dispensa/api/index.php?action=generate_recipe_stream'); +curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postData, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, +]); +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$curlErr = curl_error($ch); +curl_close($ch); +$elapsed = round(microtime(true) - $startTime, 1); + +if ($curlErr) { + fail("curl error: $curlErr"); + // Try via PHP CLI directly instead + info("Trying direct PHP execution instead..."); + // Simulate SSE output capture + ob_start(); + $_GET['action'] = 'generate_recipe_stream'; + $_SERVER['REQUEST_METHOD'] = 'POST'; + // Override php://input + $tmpFile = tempnam(sys_get_temp_dir(), 'recipe_test_'); + file_put_contents($tmpFile, $postData); + // Can't easily override php://input in CLI, skip HTTP test + ob_end_clean(); + info("HTTP test skipped (no web server on localhost) — checking SSE parsing only"); + $response = null; +} + +if ($response !== null) { + if ($httpCode !== 200) { + fail("HTTP status $httpCode (expected 200)"); + } else { + pass("HTTP 200 in {$elapsed}s"); + } + + // Parse SSE events + $events = []; + foreach (explode("\n", $response) as $line) { + if (strpos($line, 'data: ') === 0) { + $evt = json_decode(substr($line, 6), true); + if ($evt) $events[] = $evt; + } + } + + info("SSE events received: " . count($events)); + foreach ($events as $evt) { + $type = $evt['type'] ?? '?'; + $msg = $evt['message'] ?? $evt['error'] ?? json_encode($evt); + info(" [$type] $msg"); + } + + $statusEvents = array_filter($events, fn($e) => ($e['type'] ?? '') === 'status'); + $recipeEvents = array_filter($events, fn($e) => ($e['type'] ?? '') === 'recipe'); + $errorEvents = array_filter($events, fn($e) => ($e['type'] ?? '') === 'error'); + + if (!empty($errorEvents)) { + $err = reset($errorEvents); + $errMsg = $err['error'] ?? 'unknown'; + $errDetail = $err['detail'] ?? ''; + $errCode = $err['http_code'] ?? ''; + fail("Got error event: $errMsg | code=$errCode | $errDetail"); + } elseif (!empty($recipeEvents)) { + $recipe = reset($recipeEvents)['recipe'] ?? []; + pass("Got recipe: \"" . ($recipe['title'] ?? '?') . "\""); + + // Verify steps exist + if (!empty($recipe['steps']) && count($recipe['steps']) >= 2) { + pass("Recipe has " . count($recipe['steps']) . " steps"); + } else { + fail("Recipe missing steps"); + } + + // Verify meal type + if (($recipe['meal'] ?? '') === 'cena') { + pass("Meal type correct (cena)"); + } else { + fail("Meal type wrong: " . ($recipe['meal'] ?? 'missing')); + } + + // Check steps count + if (count($statusEvents) >= 3) { + pass("Got " . count($statusEvents) . " status events (agent steps working)"); + } else { + fail("Too few status events: " . count($statusEvents)); + } + } else { + fail("No recipe and no error event in SSE response"); + echo "Raw response (first 500 chars):\n" . substr($response, 0, 500) . "\n"; + } +} + +echo "\n\033[1mDone.\033[0m\n"; diff --git a/translations/de.json b/translations/de.json index 8e46aea..20eba96 100644 --- a/translations/de.json +++ b/translations/de.json @@ -26,7 +26,9 @@ "save_config": "💾 Konfiguration speichern", "save_product": "💾 Produkt speichern", "restart": "↺ Neustart", - "reset_default": "↺ Standard wiederherstellen" + "reset_default": "↺ Standard wiederherstellen", + "save_info": "💾 Info speichern", + "retry": "🔄 Erneut versuchen" }, "locations": { "dispensa": "Vorratskammer", @@ -85,24 +87,53 @@ "quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten", "banner_review_title": "Ungewöhnliche Menge", "banner_review_action_ok": "Ist korrekt", - "banner_review_action_edit": "Bearbeiten", + "banner_review_action_edit": "Korrigieren", "banner_review_action_weigh": "Wiegen", "banner_review_dismiss": "Ignorieren", "banner_prediction_title": "Ungewöhnlicher Verbrauch", "banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.", - "banner_prediction_action_confirm": "Menge bestätigen", - "banner_prediction_action_weigh": "Mit Waage wiegen", - "banner_prediction_action_edit": "Korrigieren", + "banner_prediction_action_confirm": "{qty} {unit} bestätigen", + "banner_prediction_action_weigh": "Jetzt wiegen", + "banner_prediction_action_edit": "Menge aktualisieren", "banner_expired_title": "Abgelaufenes Produkt", "banner_expired_today": "Heute abgelaufen", "banner_expired_days": "Seit {days} Tagen abgelaufen", "banner_expired_action_use": "Trotzdem verwenden", - "banner_expired_action_throw": "Wegwerfen", + "banner_expired_action_throw": "Habe ich weggeworfen", + "banner_expired_action_edit": "Datum korrigieren", + "banner_anomaly_action_edit": "Bestand korrigieren", + "banner_anomaly_action_dismiss": "Menge ist korrekt", "banner_expiring_title": "Bald ablaufend", "banner_expiring_today": "Läuft heute ab!", "banner_expiring_tomorrow": "Läuft morgen ab", "banner_expiring_days": "Läuft in {days} Tagen ab", - "banner_expiring_action_use": "Jetzt verwenden" + "banner_expiring_action_use": "Jetzt verwenden", + "banner_finished_title": "aufgebraucht?", + "banner_finished_detail": "Ich habe vermerkt, dass {name} auf null gesunken ist. Ist es wirklich leer, oder hast du noch welches?", + "banner_finished_action_yes": "Ja, aufgebraucht", + "banner_finished_action_no": "Nein, ich habe noch welches", + "banner_review_unusual_pkg_title": "Ungewöhnliche Packungsgröße", + "banner_review_unusual_pkg_detail": "Du hast eine Packung von {qty} {unit} eingestellt — die Größe scheint sehr groß. Überprüfe ob es korrekt ist.", + "banner_review_low_qty_title": "Sehr geringe Menge", + "banner_review_low_qty_detail": "Du hast nur {qty} im Bestand — das scheint sehr wenig, möglicherweise ein Eingabefehler. Bestätige wenn korrekt.", + "banner_review_high_qty_title": "Ungewöhnlich hohe Menge", + "banner_review_high_qty_detail": "Du hast {qty} im Bestand — die Zahl scheint sehr hoch. Bestätige wenn korrekt oder korrigiere.", + "banner_prediction_rate_day": "Durchschnitt ~{n} {unit}/Tag", + "banner_prediction_rate_week": "Durchschnitt ~{n} {unit}/Woche", + "banner_prediction_days_ago": "Vor {n} Tagen aufgefüllt", + "banner_prediction_more": "Ich erwartete {expected} {unit}{time}, du hast aber {actual} {unit}. Hast du Bestand ohne Buchung hinzugefügt?", + "banner_prediction_less": "Ich erwartete {expected} {unit}{time}, du hast aber nur {actual} {unit}. Hast du mehr als üblich verbraucht?", + "banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.", + "banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.", + "banner_finished_check": "Kannst du nachschauen?", + "banner_anomaly_phantom_title": "mehr Bestand als erwartet", + "banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?", + "banner_anomaly_ghost_title": "weniger Bestand als erwartet", + "banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?", + "consumed": "Verbraucht: {n} ({pct}%)", + "wasted": "Weggeworfen: {n} ({pct}%)", + "more_opened": "und {n} weitere geöffnet...", + "banner_expired_detail": "{when} · du hast noch {qty}." }, "inventory": { "title": "Vorrat", @@ -111,7 +142,19 @@ "recent_title": "🕐 Zuletzt verwendet", "popular_title": "⭐ Meistverwendet", "empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!", - "no_items_found": "Keine Bestandseinträge gefunden" + "no_items_found": "Keine Bestandseinträge gefunden", + "qty_remainder_suffix": "übrig", + "vacuum_badge": "🫙 Vakuumiert", + "opened_badge": "📭 Geöffnet", + "label_expiry": "📅 Ablaufdatum", + "label_storage": "🫙 Aufbewahrung", + "label_status": "📭 Status", + "opened_since": "Geöffnet seit {date}", + "label_position": "📍 Standort", + "label_quantity": "📦 Menge", + "label_added": "📅 Hinzugefügt", + "empty_text": "Keine Produkte hier.
Scanne ein Produkt, um es hinzuzufügen!", + "empty_db": "Keine Produkte in der Datenbank.
Scanne ein Produkt, um loszulegen!" }, "scan": { "title": "Produkt scannen", @@ -133,7 +176,13 @@ "add_btn": "📥 HINZUFÜGEN", "add_sub": "in Vorrat/Kühlschrank", "use_btn": "📤 VERWENDEN / VERBRAUCHEN", - "use_sub": "aus Vorrat/Kühlschrank" + "use_sub": "aus Vorrat/Kühlschrank", + "have_title": "📦 Schon auf Lager!", + "add_more_sub": "weitere Menge", + "use_qty_sub": "wie viel verwendet", + "throw_btn": "🗑️ ENTSORGEN", + "throw_sub": "wegwerfen", + "edit_sub": "Ablauf, Ort…" }, "add": { "title": "Zum Vorrat hinzufügen", @@ -143,7 +192,21 @@ "conf_size_placeholder": "z.B. 300", "vacuum_label": "🫙 Vakuumiert", "vacuum_hint": "Ablaufdatum wird automatisch verlängert", - "submit": "✅ Hinzufügen" + "submit": "✅ Hinzufügen", + "purchase_type_label": "🛒 Dieses Produkt ist...", + "new_btn": "🆕 Gerade gekauft", + "existing_btn": "📦 Hatte ich schon", + "remaining_label": "📦 Verbleibende Menge", + "remaining_hint": "Ungefähr wie viel ist noch übrig?", + "remaining_full": "🟢 Voll", + "remaining_half": "🟠 Halb", + "estimated_expiry": "Geschätzte Haltbarkeit:", + "suffix_freezer": "(Tiefkühler)", + "suffix_vacuum": "(vakuumversiegelt)", + "hint_modify": "📝 Du kannst das Datum ändern oder mit der Kamera scannen", + "scan_expiry_title": "📷 Ablaufdatum scannen", + "product_added": "✅ {name} hinzugefügt!{qty}", + "suffix_freezer_vacuum": "(Tiefkühler + vakuumversiegelt)" }, "use": { "title": "Verwenden / Verbrauchen", @@ -154,7 +217,18 @@ "submit": "📤 Diese Menge verwenden", "available": "📦 Verfügbar:", "not_in_inventory": "⚠️ Produkt nicht im Bestand.", - "expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!" + "expiry_warning": "⚠️ Verwende zuerst die{loc}, die am {date} abläuft — {when}!", + "throw_title": "🗑️ Produkt entsorgen", + "throw_all": "🗑️ ALLES entsorgen ({qty})", + "throw_qty_label": "Wie viel wegwerfen?", + "throw_qty_hint": "oder Menge angeben:", + "throw_partial_btn": "🗑️ Diese Menge entsorgen", + "when_expired": "seit {n} Tagen abgelaufen", + "when_today": "läuft heute ab", + "when_tomorrow": "läuft morgen ab", + "when_days": "läuft in {n} Tagen ab", + "toast_used": "📤 {qty} von {name} verwendet", + "toast_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt" }, "product": { "title_new": "Neues Produkt", @@ -186,7 +260,9 @@ "edit_catalog": "⚙️ Produktinfo bearbeiten (Name, Marke, Kategorie…)", "not_recognized": "⚠️ Produkt nicht erkannt", "edit_info": "✏️ Informationen bearbeiten", - "modify_details": "BEARBEITEN\nAblauf, Ort…" + "modify_details": "BEARBEITEN\nAblauf, Ort…", + "already_in_pantry": "📋 Bereits im Vorratsschrank", + "no_barcode": "Kein Barcode" }, "products": { "title": "📦 Alle Produkte", @@ -224,7 +300,33 @@ "smart_already": "📊 Intelligenter Einkauf sagt bereits {name} voraus", "all_searched": "Alle Produkte wurden bereits gesucht. Nutze 🔄 für einzelne Suchen.", "search_complete": "Suche abgeschlossen: {count} Produkte", - "removed_sufficient": "🧹 {removed} Produkt(e) mit ausreichendem Bestand von der Liste entfernt" + "removed_sufficient": "🧹 {removed} Produkt(e) mit ausreichendem Bestand von der Liste entfernt", + "bring_badge": "🛒 Schon auf Bring!", + "add_urgent_toast": "🔴 {n} dringende(s) Produkt(e) automatisch zu Bring! hinzugefügt", + "migration_done": "✅ {migrated} aktualisiert, {skipped} bereits ok", + "added_to_bring": "🛒 {n} Produkte zu Bring! hinzugefügt", + "added_to_bring_skip": "{n} bereits vorhanden", + "all_on_bring": "Alle Produkte waren bereits auf Bring!", + "freq_high": "📈 Häufig", + "freq_regular": "📊 Regelmäßig", + "freq_occasional": "📉 Gelegentlich", + "out_of_stock": "Ausverkauft", + "scan_toast": "📷 Scannen: {name}", + "empty_category": "Keine Produkte in dieser Kategorie", + "session_empty": "🛒 Noch keine Produkte", + "urgency_critical": "Dringend", + "urgency_high": "Bald", + "urgency_medium": "Planen", + "urgency_low": "Vorschau", + "urgency_medium_short": "Mittel", + "urgency_low_short": "Ok", + "tag_urgent": "🔴 Dringend", + "tag_priority": "⭐ Priorität", + "tag_check": "✅ Prüfen", + "smart_already_predicted": "📊 Einkauf wird bereits vorhergesagt: {name}{urgency}.", + "item_removed": "✅ {name} von der Liste entfernt!", + "urgency_spec_critical": "⚡ Dringend", + "urgency_spec_high": "🟠 Bald" }, "ai": { "title": "🤖 KI-Identifikation", @@ -233,10 +335,27 @@ "hint": "Mache ein Foto des Produkts und die KI versucht es zu identifizieren", "identifying": "🤖 Identifiziere Produkt...", "no_api_key": "⚠️ Gemini API-Schlüssel nicht konfiguriert.\nFüge GEMINI_API_KEY in der .env Datei auf dem Server hinzu.", - "fields_filled": "✅ Felder von KI ausgefüllt" + "fields_filled": "✅ Felder von KI ausgefüllt", + "use_data": "✅ KI-Daten verwenden", + "use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)" }, "log": { - "title": "� Verlauf" + "title": "� Verlauf", + "type_added": "Hinzugefügt", + "type_waste": "Entsorgt", + "type_used": "Verwendet", + "type_bring": "Zu Bring! hinzugefügt", + "undone_badge": "Rückgängig", + "undo_title": "Diese Operation rückgängig machen", + "load_error": "Fehler beim Laden des Verlaufs", + "empty": "Keine Operationen aufgezeichnet.", + "undo_action_remove": "Entfernen von", + "undo_action_restore": "Wiederherstellen von", + "undo_confirm": "Vorgang rückgängig machen?\n→ {action} {name}", + "undo_success": "↩ Vorgang rückgängig gemacht für {name}", + "already_undone": "Vorgang bereits rückgängig gemacht", + "too_old": "Vorgänge älter als 24 Stunden können nicht rückgängig gemacht werden", + "undo_error": "Fehler beim Rückgängigmachen" }, "chat": { "title": "Gemini Chef", @@ -247,7 +366,12 @@ "suggestion_light": "🥗 Etwas Leichtes", "suggestion_expiry": "⏰ Ablaufende nutzen", "clear": "Neues Gespräch", - "placeholder": "Frag etwas..." + "placeholder": "Frag etwas...", + "cleared": "Chat geleert", + "suggestion_snack_text": "Was kann ich als schnellen Snack machen?", + "suggestion_juice_text": "Mach mir einen Saft oder Smoothie mit dem was ich habe", + "suggestion_light_text": "Ich habe Hunger, möchte aber etwas Leichtes", + "suggestion_expiry_text": "Was läuft bald ab und wie kann ich es verwenden?" }, "cooking": { "close": "Schließen", @@ -256,7 +380,13 @@ "replay": "🔊 Nochmal", "timer": "⏱️ {time} · Timer", "prev": "◀ Zurück", - "next": "Weiter ▶" + "next": "Weiter ▶", + "ingredient_used": "✔️ Abgezogen", + "ingredient_use_btn": "📦 Verwenden", + "ingredient_deduct_title": "Von Vorrat abziehen", + "timer_expired_tts": "Timer {label} abgelaufen!", + "timer_warning_tts": "Achtung! {label}: noch 10 Sekunden!", + "recipe_done_tts": "Rezept abgeschlossen! Guten Appetit!" }, "settings": { "title": "⚙️ Einstellungen", @@ -401,12 +531,45 @@ "days": "{days} Tage", "expired_days": "Seit {days}T", "expired_yesterday": "Seit gestern", - "expired_today": "Heute" + "expired_today": "Heute", + "badge_today": "⚠️ Läuft heute ab!", + "badge_tomorrow": "⏰ Morgen", + "badge_tomorrow_long": "⏰ Läuft morgen ab", + "badge_days": "⏰ {n} Tage", + "badge_expired_ago": "⚠️ Seit {n}T abgel.", + "badge_expired": "⛔ Abgelaufen!", + "badge_stable": "✅ Stabil", + "badge_expiring_short": "⏰ Läuft in {n}T ab", + "badge_ok_still": "✅ Noch {n}T", + "badge_expires_red": "🔴 In {n}T", + "badge_expires_yellow": "🟡 In {n}T", + "badge_expired_bare": "⚠️ Abgelaufen", + "badge_expires_warn": "⚠️ In {n}T", + "badge_days_left": "⏳ ~{n}T übrig", + "days_approx": "~{n} Tage", + "weeks_approx": "~{n} Wochen", + "months_approx": "~{n} Monate", + "years_approx": "~{n} Jahre", + "expired_today_long": "Heute abgelaufen", + "expired_ago_long": "Seit {n} Tagen abgelaufen", + "expired_suffix": "— Abgelaufen!", + "days_compact": "{n}T" }, "status": { "ok": "OK", "check": "Prüfen", - "discard": "Entsorgen" + "discard": "Entsorgen", + "tip_freezer_ok": "Im Gefrierschrank: noch sicher (~{n}T Puffer)", + "tip_freezer_check": "Seit langem im Gefrierschrank, könnte an Qualität verloren haben. Bald verbrauchen", + "tip_freezer_danger": "Zu lange im Gefrierschrank, Gefrierbrand- und Qualitätsverlust-Risiko", + "tip_highRisk_check": "Kürzlich abgelaufen, Geruch und Aussehen vor dem Verzehr prüfen", + "tip_highRisk_danger": "Verderbliches Produkt abgelaufen: aus Sicherheitsgründen entsorgen", + "tip_medRisk_check1": "Aussehen und Geruch vor dem Verzehr prüfen", + "tip_medRisk_check2": "Schon eine Weile abgelaufen, vor dem Verzehr gut prüfen", + "tip_medRisk_danger": "Zu lange seit dem Ablaufdatum, lieber entsorgen", + "tip_lowRisk_ok": "Haltbares Produkt, noch sicher zu verzehren", + "tip_lowRisk_check": "Seit über einem Monat abgelaufen, Verpackungsintegrität prüfen", + "tip_lowRisk_danger": "Zu lange abgelaufen, besser kein Risiko eingehen" }, "toast": { "product_saved": "Produkt gespeichert!", @@ -423,6 +586,7 @@ "finished_to_bring": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt", "thrown_away": "🗑️ {name} weggeworfen!", "thrown_away_partial": "🗑️ {qty} {unit} von {name} weggeworfen", + "product_finished_confirmed": "✅ Entfernt — wieder hinzufügen, wenn du nachkaufst", "appliance_added": "Gerät hinzugefügt", "item_added": "{name} hinzugefügt" }, @@ -439,18 +603,23 @@ "bring_add": "Fehler beim Hinzufügen zu Bring!", "bring_connection": "Bring! Verbindungsfehler", "identification": "Identifikationsfehler", + "ai_quota": "KI-Kontingent erschöpft. Bitte in ein paar Minuten erneut versuchen.", "barcode_empty": "Barcode eingeben", "barcode_format": "Barcode darf nur Zahlen enthalten (4-14 Ziffern)", "min_chars": "Mindestens 2 Zeichen eingeben", "not_in_inventory": "Produkt nicht im Bestand", "appliance_exists": "Gerät bereits vorhanden", - "already_exists": "Bereits vorhanden" + "already_exists": "Bereits vorhanden", + "network_retry": "Verbindungsfehler. Erneut versuchen." }, "confirm": { - "remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?" + "remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?", + "kiosk_exit": "Kioskmodus verlassen?" }, "edit": { - "title": "{name} bearbeiten" + "title": "{name} bearbeiten", + "unknown_hint": "Produktname und Informationen eingeben", + "label_name": "🏷️ Produktname" }, "screensaver": { "recipe_btn": "Rezepte", @@ -485,11 +654,60 @@ "timeout": "Timeout: keine Antwort vom Gateway", "error_connect": "Verbindung zum Gateway nicht möglich", "tab": "Smart-Waage", - "low_weight": "Gewicht < 10 g · manuell eingeben\n(Auto-Erkennung erfordert mind. 10 g)" + "low_weight": "Gewicht < 10 g · manuell eingeben\n(Auto-Erkennung erfordert mind. 10 g)", + "density_hint": "(Dichte {density} g/ml)", + "ml_hint": "(wird in ml umgerechnet)", + "weight_detected": "Gewicht erkannt — 10s Stabilität abwarten…", + "weight_too_low": "Gewicht zu niedrig — warten…", + "stable": "✓ Stabil", + "auto_confirm": "✅ {val} {unit} — Auto-Bestätigung in 5s (tippen zum Abbrechen)", + "cancelled_replace": "Abgebrochen — lege die Zutat wieder auf die Waage, um fortzufahren" }, "prediction": { "expected_qty": "Erwartet: {expected} {unit}", "actual_qty": "Aktuell: {actual} {unit}", "check_suggestion": "Überprüfe oder wiege die Restmenge" + }, + "date": { + "today": "📅 Heute", + "yesterday": "📅 Gestern" + }, + "scanner": { + "title_barcode": "🔖 Barcode scannen", + "barcode_hint": "Produktbarcode einrahmen", + "barcode_manual_placeholder": "Oder manuell eingeben...", + "barcode_use_btn": "✅ Diesen Code verwenden", + "ai_identifying": "🤖 Produkt wird erkannt...", + "ai_analyzing": "🤖 KI-Analyse läuft...", + "product_label_hint": "Produktetikett einrahmen", + "expiry_label_hint": "Ablaufdatum auf dem Produkt einrahmen", + "capture_btn": "📸 Aufnehmen", + "capture_photo_btn": "📸 Foto aufnehmen", + "retake_btn": "🔄 Erneut aufnehmen", + "camera_error_hint": "Stelle sicher, dass du HTTPS verwendest und Kameraberechtigungen erteilt hast.
Du kannst den Barcode manuell eingeben oder die KI-Identifikation verwenden.", + "no_barcode": "Kein Barcode" + }, + "lowstock": { + "title": "⚠️ Wird knapp!", + "message": "{name} wird knapp — nur noch {qty} übrig.", + "question": "Möchtest du es zur Einkaufsliste hinzufügen?", + "yes": "🛒 Ja, zu Bring! hinzufügen", + "no": "Nein, passt für jetzt" + }, + "move": { + "title": "📦 Den Rest bewegen?", + "question": "Möchtest du {thing} von {name} an einen anderen Ort bewegen?", + "question_short": "Möchtest du {thing} an einen anderen Ort bewegen?", + "thing_opened": "die offene Packung", + "thing_rest": "den Rest", + "stay_btn": "Nein, bleibt in {location}", + "moved_toast": "📦 Offene Packung bewegt nach {location}", + "vacuum_restore": "🫙 Vakuum wiederherstellen" + }, + "nova": { + "1": "Unverarbeitet", + "2": "Kulinarische Zutat", + "3": "Verarbeitet", + "4": "Hochverarbeitet" } -} \ No newline at end of file +} diff --git a/translations/en.json b/translations/en.json index df7a6e5..2ac0d72 100644 --- a/translations/en.json +++ b/translations/en.json @@ -26,7 +26,9 @@ "save_config": "💾 Save Configuration", "save_product": "💾 Save Product", "restart": "↺ Restart", - "reset_default": "↺ Reset to default" + "reset_default": "↺ Reset to default", + "save_info": "💾 Save information", + "retry": "🔄 Retry" }, "locations": { "dispensa": "Pantry", @@ -85,24 +87,53 @@ "quick_recipe": "🍳 Quick recipe with expiring products", "banner_review_title": "Anomalous quantity", "banner_review_action_ok": "It's correct", - "banner_review_action_edit": "Edit", + "banner_review_action_edit": "Correct", "banner_review_action_weigh": "Weigh", "banner_review_dismiss": "Dismiss", "banner_prediction_title": "Anomalous consumption", "banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.", - "banner_prediction_action_confirm": "Confirm quantity", - "banner_prediction_action_weigh": "Weigh with scale", - "banner_prediction_action_edit": "Correct", + "banner_prediction_action_confirm": "Confirm {qty} {unit} is correct", + "banner_prediction_action_weigh": "Weigh now", + "banner_prediction_action_edit": "Update quantity", "banner_expired_title": "Expired product", "banner_expired_today": "Expired today", "banner_expired_days": "Expired {days} days ago", "banner_expired_action_use": "Use anyway", - "banner_expired_action_throw": "Throw away", + "banner_expired_action_throw": "I threw it away", + "banner_expired_action_edit": "Fix date", + "banner_anomaly_action_edit": "Fix inventory", + "banner_anomaly_action_dismiss": "Quantity is correct", "banner_expiring_title": "Expiring soon", "banner_expiring_today": "Expires today!", "banner_expiring_tomorrow": "Expires tomorrow", "banner_expiring_days": "Expires in {days} days", - "banner_expiring_action_use": "Use now" + "banner_expiring_action_use": "Use now", + "banner_finished_title": "finished?", + "banner_finished_detail": "I recorded that {name} reached zero stock. Is it really gone, or do you still have some?", + "banner_finished_action_yes": "Yes, it's done", + "banner_finished_action_no": "No, I still have some", + "banner_review_unusual_pkg_title": "Unusual package size", + "banner_review_unusual_pkg_detail": "You set a package of {qty} {unit} — the size seems very large. Check if correct or edit.", + "banner_review_low_qty_title": "Very low quantity", + "banner_review_low_qty_detail": "You only have {qty} in stock — seems very little, could be a typo. Confirm if correct.", + "banner_review_high_qty_title": "Unusually high quantity", + "banner_review_high_qty_detail": "You have {qty} in stock — the figure seems very high. Confirm if correct or edit.", + "banner_prediction_rate_day": "Average ~{n} {unit}/day", + "banner_prediction_rate_week": "Average ~{n} {unit}/week", + "banner_prediction_days_ago": "{n} days ago you restocked", + "banner_prediction_more": "I expected {expected} {unit}{time}, but you have {actual} {unit}. Did you add stock without recording it?", + "banner_prediction_less": "I expected {expected} {unit}{time}, but you only have {actual} {unit}. Did you use more than usual?", + "banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.", + "banner_finished_expected": "According to records you should still have {qty} {unit}.", + "banner_finished_check": "Can you check?", + "banner_anomaly_phantom_title": "you have more stock than expected", + "banner_anomaly_phantom_detail": "Inventory shows {inv_qty} {unit}, but based on records you should only have {expected_qty} {unit}. Did you add stock without recording it?", + "banner_anomaly_ghost_title": "you have less stock than expected", + "banner_anomaly_ghost_detail": "Based on recorded operations you should have {expected_qty} {unit} of {name}, but inventory shows only {inv_qty} {unit}. Did you take stock without recording it?", + "consumed": "Consumed: {n} ({pct}%)", + "wasted": "Wasted: {n} ({pct}%)", + "more_opened": "and {n} more opened...", + "banner_expired_detail": "{when} · you still have {qty}." }, "inventory": { "title": "Pantry", @@ -111,7 +142,19 @@ "recent_title": "🕐 Recently used", "popular_title": "⭐ Most used", "empty": "No products here.\nScan a product to add it!", - "no_items_found": "No inventory items found" + "no_items_found": "No inventory items found", + "qty_remainder_suffix": "left", + "vacuum_badge": "🫙 Vacuum sealed", + "opened_badge": "📭 Opened", + "label_expiry": "📅 Expiry", + "label_storage": "🫙 Storage", + "label_status": "📭 Status", + "opened_since": "Opened since {date}", + "label_position": "📍 Location", + "label_quantity": "📦 Quantity", + "label_added": "📅 Added", + "empty_text": "No products here.
Scan a product to add it!", + "empty_db": "No products in the database.
Scan a product to get started!" }, "scan": { "title": "Scan Product", @@ -133,7 +176,13 @@ "add_btn": "📥 ADD", "add_sub": "to pantry/fridge", "use_btn": "📤 USE / CONSUME", - "use_sub": "from pantry/fridge" + "use_sub": "from pantry/fridge", + "have_title": "📦 Already in stock!", + "add_more_sub": "add more", + "use_qty_sub": "how much you used", + "throw_btn": "🗑️ DISCARD", + "throw_sub": "throw away", + "edit_sub": "expiry, location…" }, "add": { "title": "Add to Pantry", @@ -143,7 +192,21 @@ "conf_size_placeholder": "e.g. 300", "vacuum_label": "🫙 Vacuum sealed", "vacuum_hint": "Expiry date will be extended automatically", - "submit": "✅ Add" + "submit": "✅ Add", + "purchase_type_label": "🛒 This product is...", + "new_btn": "🆕 Just bought", + "existing_btn": "📦 I already had it", + "remaining_label": "📦 Remaining quantity", + "remaining_hint": "Approximately how much is left?", + "remaining_full": "🟢 Full", + "remaining_half": "🟠 Half", + "estimated_expiry": "Estimated expiry:", + "suffix_freezer": "(freezer)", + "suffix_vacuum": "(vacuum sealed)", + "hint_modify": "📝 You can change the date or scan it with the camera", + "scan_expiry_title": "📷 Scan Expiry Date", + "product_added": "✅ {name} added!{qty}", + "suffix_freezer_vacuum": "(freezer + vacuum sealed)" }, "use": { "title": "Use / Consume", @@ -154,7 +217,18 @@ "submit": "📤 Use this quantity", "available": "📦 Available:", "not_in_inventory": "⚠️ Product not in inventory.", - "expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!" + "expiry_warning": "⚠️ Use first the one{loc} that expires on {date} — {when}!", + "throw_title": "🗑️ Discard Product", + "throw_all": "🗑️ Discard ALL ({qty})", + "throw_qty_label": "How much to discard?", + "throw_qty_hint": "or enter a quantity:", + "throw_partial_btn": "🗑️ Discard this quantity", + "when_expired": "expired {n} days ago", + "when_today": "expires today", + "when_tomorrow": "expires tomorrow", + "when_days": "expires in {n} days", + "toast_used": "📤 Used {qty} of {name}", + "toast_bring": "🛒 Product finished → added to Bring!" }, "product": { "title_new": "New Product", @@ -186,7 +260,9 @@ "edit_catalog": "⚙️ Edit product info (name, brand, category…)", "not_recognized": "⚠️ Product not recognized", "edit_info": "✏️ Edit information", - "modify_details": "EDIT\nexpiry, location…" + "modify_details": "EDIT\nexpiry, location…", + "already_in_pantry": "📋 Already in pantry", + "no_barcode": "No barcode" }, "products": { "title": "📦 All Products", @@ -224,7 +300,33 @@ "smart_already": "📊 Smart shopping already predicts {name}", "all_searched": "All products have already been searched. Use 🔄 to search individual ones.", "search_complete": "Search complete: {count} products", - "removed_sufficient": "🧹 {removed} product(s) with sufficient stock removed from the list" + "removed_sufficient": "🧹 {removed} product(s) with sufficient stock removed from the list", + "bring_badge": "🛒 Already on Bring!", + "add_urgent_toast": "🔴 {n} urgent product(s) automatically added to Bring!", + "migration_done": "✅ {migrated} updated, {skipped} already ok", + "added_to_bring": "🛒 {n} products added to Bring!", + "added_to_bring_skip": "{n} already present", + "all_on_bring": "All products were already on Bring!", + "freq_high": "📈 Frequent", + "freq_regular": "📊 Regular", + "freq_occasional": "📉 Occasional", + "out_of_stock": "Out of stock", + "scan_toast": "📷 Scan: {name}", + "empty_category": "No products in this category", + "session_empty": "🛒 No products yet", + "urgency_critical": "Urgent", + "urgency_high": "Soon", + "urgency_medium": "Plan", + "urgency_low": "Forecast", + "urgency_medium_short": "Medium", + "urgency_low_short": "Ok", + "tag_urgent": "🔴 Urgent", + "tag_priority": "⭐ Priority", + "tag_check": "✅ Check", + "smart_already_predicted": "📊 Smart shopping already predicts {name}{urgency}.", + "item_removed": "✅ {name} removed from list!", + "urgency_spec_critical": "⚡ Urgent", + "urgency_spec_high": "🟠 Soon" }, "ai": { "title": "🤖 AI Identification", @@ -233,10 +335,27 @@ "hint": "Take a photo of the product and AI will try to identify it", "identifying": "🤖 Identifying product...", "no_api_key": "⚠️ Gemini API key not configured.\nAdd GEMINI_API_KEY to the .env file on the server.", - "fields_filled": "✅ Fields filled by AI" + "fields_filled": "✅ Fields filled by AI", + "use_data": "✅ Use AI data", + "use_data_no_barcode": "✅ Use AI data (no barcode)" }, "log": { - "title": "📒 Operations Log" + "title": "📒 Operations Log", + "type_added": "Added", + "type_waste": "Discarded", + "type_used": "Used", + "type_bring": "Added to Bring!", + "undone_badge": "Undone", + "undo_title": "Undo this operation", + "load_error": "Error loading log", + "empty": "No operations recorded.", + "undo_action_remove": "removal of", + "undo_action_restore": "restock of", + "undo_confirm": "Undo this operation?\n→ {action} {name}", + "undo_success": "↩ Operation undone for {name}", + "already_undone": "Operation already undone", + "too_old": "Cannot undo operations older than 24 hours", + "undo_error": "Error during undo" }, "chat": { "title": "Gemini Chef", @@ -247,7 +366,12 @@ "suggestion_light": "🥗 Something light", "suggestion_expiry": "⏰ Use expiring items", "clear": "New conversation", - "placeholder": "Ask something..." + "placeholder": "Ask something...", + "cleared": "Chat cleared", + "suggestion_snack_text": "What can I make for a quick snack?", + "suggestion_juice_text": "Make me a juice or smoothie with what I have", + "suggestion_light_text": "I'm hungry but want something light", + "suggestion_expiry_text": "What's about to expire and how can I use it?" }, "cooking": { "close": "Close", @@ -256,7 +380,13 @@ "replay": "🔊 Replay", "timer": "⏱️ {time} · Timer", "prev": "◀ Previous", - "next": "Next ▶" + "next": "Next ▶", + "ingredient_used": "✔️ Deducted", + "ingredient_use_btn": "📦 Use", + "ingredient_deduct_title": "Deduct from pantry", + "timer_expired_tts": "Timer {label} expired!", + "timer_warning_tts": "Heads up! {label}: 10 seconds left!", + "recipe_done_tts": "Recipe complete! Enjoy your meal!" }, "settings": { "title": "⚙️ Settings", @@ -401,12 +531,45 @@ "days": "{days} days", "expired_days": "{days}d ago", "expired_yesterday": "Yesterday", - "expired_today": "Today" + "expired_today": "Today", + "badge_today": "⚠️ Expires today!", + "badge_tomorrow": "⏰ Tomorrow", + "badge_tomorrow_long": "⏰ Expires tomorrow", + "badge_days": "⏰ {n} days", + "badge_expired_ago": "⚠️ Expired {n}d ago", + "badge_expired": "⛔ Expired!", + "badge_stable": "✅ Stable", + "badge_expiring_short": "⏰ Exp. in {n}d", + "badge_ok_still": "✅ Still {n}d", + "badge_expires_red": "🔴 Exp. in {n}d", + "badge_expires_yellow": "🟡 Exp. in {n}d", + "badge_expired_bare": "⚠️ Expired", + "badge_expires_warn": "⚠️ Exp. in {n}d", + "badge_days_left": "⏳ ~{n}d left", + "days_approx": "~{n} days", + "weeks_approx": "~{n} weeks", + "months_approx": "~{n} months", + "years_approx": "~{n} years", + "expired_today_long": "Expired today", + "expired_ago_long": "Expired {n} days ago", + "expired_suffix": "— Expired!", + "days_compact": "{n}d" }, "status": { "ok": "OK", "check": "Check", - "discard": "Discard" + "discard": "Discard", + "tip_freezer_ok": "In freezer: still safe (~{n}d margin)", + "tip_freezer_check": "In freezer for a long time, may have lost quality. Consume soon", + "tip_freezer_danger": "In freezer too long, risk of freezer burn and degradation", + "tip_highRisk_check": "Expired recently, check smell and appearance before consuming", + "tip_highRisk_danger": "Perishable product expired: discard for safety", + "tip_medRisk_check1": "Check appearance and smell before consuming", + "tip_medRisk_check2": "Expired a while ago, check carefully before use", + "tip_medRisk_danger": "Too long since expiry, better to discard", + "tip_lowRisk_ok": "Long-lasting product, still safe to consume", + "tip_lowRisk_check": "Expired over a month ago, check package integrity", + "tip_lowRisk_danger": "Expired too long ago, better not to risk it" }, "toast": { "product_saved": "Product saved!", @@ -423,6 +586,7 @@ "finished_to_bring": "🛒 Product finished → added to Bring!", "thrown_away": "🗑️ {name} thrown away!", "thrown_away_partial": "🗑️ Thrown away {qty} {unit} of {name}", + "product_finished_confirmed": "✅ Removed — add it again when you restock", "appliance_added": "Appliance added", "item_added": "{name} added" }, @@ -439,18 +603,23 @@ "bring_add": "Error adding to Bring!", "bring_connection": "Bring! connection error", "identification": "Identification error", + "ai_quota": "AI quota exhausted. Please try again in a few minutes.", "barcode_empty": "Enter a barcode", "barcode_format": "Barcode must contain only numbers (4-14 digits)", "min_chars": "Type at least 2 characters", "not_in_inventory": "Product not in inventory", "appliance_exists": "Appliance already exists", - "already_exists": "Already exists" + "already_exists": "Already exists", + "network_retry": "Connection error. Try again." }, "confirm": { - "remove_item": "Do you really want to remove this product from inventory?" + "remove_item": "Do you really want to remove this product from inventory?", + "kiosk_exit": "Exit kiosk mode?" }, "edit": { - "title": "Edit {name}" + "title": "Edit {name}", + "unknown_hint": "Enter the product name and information", + "label_name": "🏷️ Product name" }, "screensaver": { "recipe_btn": "Recipes", @@ -485,11 +654,60 @@ "timeout": "Timeout: no response from gateway", "error_connect": "Cannot connect to gateway", "tab": "Smart Scale", - "low_weight": "Weight < 10 g · enter manually\n(auto-reading requires at least 10 g)" + "low_weight": "Weight < 10 g · enter manually\n(auto-reading requires at least 10 g)", + "density_hint": "(density {density} g/ml)", + "ml_hint": "(will be converted to ml)", + "weight_detected": "Weight detected — wait 10s for stability…", + "weight_too_low": "Weight too low — waiting…", + "stable": "✓ Stable", + "auto_confirm": "✅ {val} {unit} — auto-confirm in 5s (tap to cancel)", + "cancelled_replace": "Cancelled — replace the ingredient on the scale to resume" }, "prediction": { "expected_qty": "Expected: {expected} {unit}", "actual_qty": "Current: {actual} {unit}", "check_suggestion": "Check or weigh the remaining quantity" + }, + "date": { + "today": "📅 Today", + "yesterday": "📅 Yesterday" + }, + "scanner": { + "title_barcode": "🔖 Scan Barcode", + "barcode_hint": "Frame the product barcode", + "barcode_manual_placeholder": "Or enter manually...", + "barcode_use_btn": "✅ Use this code", + "ai_identifying": "🤖 Identifying product...", + "ai_analyzing": "🤖 AI analysis in progress...", + "product_label_hint": "Frame the product label", + "expiry_label_hint": "Frame the expiry date printed on the product", + "capture_btn": "📸 Capture", + "capture_photo_btn": "📸 Take Photo", + "retake_btn": "🔄 Retake", + "camera_error_hint": "Ensure you use HTTPS and have granted camera permissions.
You can enter the barcode manually or use AI identification.", + "no_barcode": "No barcode" + }, + "lowstock": { + "title": "⚠️ Running low!", + "message": "{name} is running low — only {qty} remaining.", + "question": "Do you want to add it to the shopping list?", + "yes": "🛒 Yes, add to Bring!", + "no": "No, I'm fine for now" + }, + "move": { + "title": "📦 Move the rest?", + "question": "Do you want to move the {thing} of {name} to another location?", + "question_short": "Do you want to move the {thing} to another location?", + "thing_opened": "opened package", + "thing_rest": "rest", + "stay_btn": "No, stay in {location}", + "moved_toast": "📦 Opened package moved to {location}", + "vacuum_restore": "🫙 Restore vacuum sealed" + }, + "nova": { + "1": "Unprocessed", + "2": "Culinary ingredient", + "3": "Processed", + "4": "Ultra-processed" } -} \ No newline at end of file +} diff --git a/translations/it.json b/translations/it.json index 2c084f3..26d9d8a 100644 --- a/translations/it.json +++ b/translations/it.json @@ -26,7 +26,9 @@ "save_config": "💾 Salva Configurazione", "save_product": "💾 Salva Prodotto", "restart": "↺ Ricomincia", - "reset_default": "↺ Ripristina default" + "reset_default": "↺ Ripristina default", + "save_info": "💾 Salva informazioni", + "retry": "🔄 Riprova" }, "locations": { "dispensa": "Dispensa", @@ -85,24 +87,53 @@ "quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza", "banner_review_title": "Quantità anomala", "banner_review_action_ok": "È corretto", - "banner_review_action_edit": "Modifica", + "banner_review_action_edit": "Correggi", "banner_review_action_weigh": "Pesa", "banner_review_dismiss": "Ignora", "banner_prediction_title": "Consumo anomalo", "banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.", - "banner_prediction_action_confirm": "Confermo quantità", - "banner_prediction_action_weigh": "Pesa con bilancia", - "banner_prediction_action_edit": "Correggi", + "banner_prediction_action_confirm": "Confermo la quantità di {qty} {unit}", + "banner_prediction_action_weigh": "Pesa ora", + "banner_prediction_action_edit": "Aggiorna quantità", "banner_expired_title": "Prodotto scaduto", "banner_expired_today": "Scaduto oggi", "banner_expired_days": "Scaduto da {days} giorni", "banner_expired_action_use": "Usa comunque", - "banner_expired_action_throw": "Butta via", + "banner_expired_action_throw": "L'ho buttato", + "banner_expired_action_edit": "Correggi data", + "banner_anomaly_action_edit": "Correggi inventario", + "banner_anomaly_action_dismiss": "La quantità è giusta", "banner_expiring_title": "In scadenza", "banner_expiring_today": "Scade oggi!", "banner_expiring_tomorrow": "Scade domani", "banner_expiring_days": "Scade tra {days} giorni", - "banner_expiring_action_use": "Usa ora" + "banner_expiring_action_use": "Usa ora", + "banner_finished_title": "è finito?", + "banner_finished_detail": "Ho registrato che {name} ha toccato quota zero. È davvero finito o hai ancora delle scorte?", + "banner_finished_action_yes": "Sì, è finito", + "banner_finished_action_no": "No, ne ho ancora", + "banner_review_unusual_pkg_title": "Confezione insolita", + "banner_review_unusual_pkg_detail": "Hai impostato una confezione da {qty} {unit} — la dimensione sembra molto alta. Controlla se è corretta o modifica.", + "banner_review_low_qty_title": "Quantità molto bassa", + "banner_review_low_qty_detail": "Hai solo {qty} in inventario — sembra poco, potrebbe essere un errore. Conferma se è corretto.", + "banner_review_high_qty_title": "Quantità insolitamente alta", + "banner_review_high_qty_detail": "Hai {qty} in inventario — la cifra sembra molto alta. Conferma se è corretto o correggi.", + "banner_prediction_rate_day": "Media ~{n} {unit}/giorno", + "banner_prediction_rate_week": "Media ~{n} {unit}/settimana", + "banner_prediction_days_ago": "{n} giorni fa hai rifornito", + "banner_prediction_more": "mi aspettavo {expected} {unit}{time}, ne hai invece {actual} {unit}. Hai aggiunto scorte senza registrarle?", + "banner_prediction_less": "mi aspettavo {expected} {unit}{time}, ne hai solo {actual} {unit}. Hai consumato di più del solito?", + "banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.", + "banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.", + "banner_finished_check": "Puoi controllare?", + "banner_anomaly_phantom_title": "hai più scorte del previsto", + "banner_anomaly_phantom_detail": "L'inventario segna {inv_qty} {unit}, ma in base alle registrazioni ne dovresti avere solo {expected_qty} {unit}. Hai aggiunto scorte senza registrarle?", + "banner_anomaly_ghost_title": "hai meno scorte del previsto", + "banner_anomaly_ghost_detail": "In base alle operazioni registrate dovresti avere {expected_qty} {unit} di {name}, ma l'inventario mostra solo {inv_qty} {unit}. Hai prelevato senza registrarlo?", + "consumed": "Consumati: {n} ({pct}%)", + "wasted": "Buttati: {n} ({pct}%)", + "more_opened": "e altri {n} prodotti aperti...", + "banner_expired_detail": "{when} · hai ancora {qty}." }, "inventory": { "title": "Dispensa", @@ -111,7 +142,19 @@ "recent_title": "🕐 Ultimi usati", "popular_title": "⭐ Più usati", "empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!", - "no_items_found": "Nessuna voce di inventario trovata" + "no_items_found": "Nessuna voce di inventario trovata", + "qty_remainder_suffix": "rimasti", + "vacuum_badge": "🫙 Sotto vuoto", + "opened_badge": "📭 Aperto", + "label_expiry": "📅 Scadenza", + "label_storage": "🫙 Conservazione", + "label_status": "📭 Stato", + "opened_since": "Aperto dal {date}", + "label_position": "📍 Posizione", + "label_quantity": "📦 Quantità", + "label_added": "📅 Aggiunto", + "empty_text": "Nessun prodotto qui.
Scansiona un prodotto per aggiungerlo!", + "empty_db": "Nessun prodotto nel database.
Scansiona un prodotto per iniziare!" }, "scan": { "title": "Scansiona Prodotto", @@ -133,7 +176,13 @@ "add_btn": "📥 AGGIUNGI", "add_sub": "in dispensa/frigo", "use_btn": "📤 USA / CONSUMA", - "use_sub": "dalla dispensa/frigo" + "use_sub": "dalla dispensa/frigo", + "have_title": "📦 Ce l'hai già!", + "add_more_sub": "altra quantità", + "use_qty_sub": "quanto ne hai usato", + "throw_btn": "🗑️ BUTTA", + "throw_sub": "butta il prodotto", + "edit_sub": "scadenza, luogo…" }, "add": { "title": "Aggiungi alla Dispensa", @@ -143,7 +192,21 @@ "conf_size_placeholder": "es. 300", "vacuum_label": "🫙 Sotto vuoto", "vacuum_hint": "La scadenza verrà estesa automaticamente", - "submit": "✅ Aggiungi" + "submit": "✅ Aggiungi", + "purchase_type_label": "🛒 Questo prodotto è...", + "new_btn": "🆕 Appena comprato", + "existing_btn": "📦 Ce l'avevo già", + "remaining_label": "📦 Quantità rimasta", + "remaining_hint": "Quanto è rimasto approssimativamente?", + "remaining_full": "🟢 Pieno", + "remaining_half": "🟠 Metà", + "estimated_expiry": "Scadenza stimata:", + "suffix_freezer": "(freezer)", + "suffix_vacuum": "(sotto vuoto)", + "hint_modify": "📝 Puoi modificare la data o scansionarla con la fotocamera", + "scan_expiry_title": "📷 Scansiona Data Scadenza", + "product_added": "✅ {name} aggiunto!{qty}", + "suffix_freezer_vacuum": "(freezer + sotto vuoto)" }, "use": { "title": "Usa / Consuma", @@ -154,7 +217,18 @@ "submit": "📤 Usa questa quantità", "available": "📦 Disponibile:", "not_in_inventory": "⚠️ Prodotto non presente nell'inventario.", - "expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!" + "expiry_warning": "⚠️ Usa prima quella{loc} che scade il {date} — {when}!", + "throw_title": "🗑️ Butta Prodotto", + "throw_all": "🗑️ Butta TUTTO ({qty})", + "throw_qty_label": "Quanto butti?", + "throw_qty_hint": "oppure specifica la quantità:", + "throw_partial_btn": "🗑️ Butta questa quantità", + "when_expired": "scaduta da {n} giorni", + "when_today": "scade oggi", + "when_tomorrow": "scade domani", + "when_days": "scade tra {n} giorni", + "toast_used": "📤 Usato {qty} di {name}", + "toast_bring": "🛒 Prodotto finito → aggiunto a Bring!" }, "product": { "title_new": "Nuovo Prodotto", @@ -186,7 +260,9 @@ "edit_catalog": "⚙️ Modifica scheda prodotto (nome, marca, categoria…)", "not_recognized": "⚠️ Prodotto non riconosciuto", "edit_info": "✏️ Modifica informazioni", - "modify_details": "MODIFICA\nscadenza, luogo…" + "modify_details": "MODIFICA\nscadenza, luogo…", + "already_in_pantry": "📋 Già in dispensa", + "no_barcode": "Senza barcode" }, "products": { "title": "📦 Tutti i Prodotti", @@ -224,7 +300,33 @@ "smart_already": "📊 La spesa intelligente prevede già {name}", "all_searched": "Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.", "search_complete": "Ricerca completata: {count} prodotti", - "removed_sufficient": "🧹 {removed} prodotto/i con scorte sufficienti rimosso/i dalla lista" + "removed_sufficient": "🧹 {removed} prodotto/i con scorte sufficienti rimosso/i dalla lista", + "bring_badge": "🛒 Già su Bring!", + "add_urgent_toast": "🔴 {n} prodotto/i urgente/i aggiunto/i automaticamente a Bring!", + "migration_done": "✅ {migrated} aggiornati, {skipped} già ok", + "added_to_bring": "🛒 {n} prodotti aggiunti a Bring!", + "added_to_bring_skip": "{n} già presenti", + "all_on_bring": "Tutti i prodotti erano già su Bring!", + "freq_high": "📈 Uso frequente", + "freq_regular": "📊 Uso regolare", + "freq_occasional": "📉 Uso occasionale", + "out_of_stock": "Esaurito", + "scan_toast": "📷 Scansiona: {name}", + "empty_category": "Nessun prodotto in questa categoria", + "session_empty": "🛒 Nessun prodotto ancora", + "urgency_critical": "Urgente", + "urgency_high": "Presto", + "urgency_medium": "Pianifica", + "urgency_low": "Previsione", + "urgency_medium_short": "Medio", + "urgency_low_short": "Ok", + "tag_urgent": "🔴 Urgente", + "tag_priority": "⭐ Priorità", + "tag_check": "✅ Verificare", + "smart_already_predicted": "📊 La spesa intelligente prevede già {name}{urgency}.", + "item_removed": "✅ {name} rimosso dalla lista!", + "urgency_spec_critical": "⚡ Urgente", + "urgency_spec_high": "🟠 Presto" }, "ai": { "title": "🤖 Identificazione AI", @@ -233,10 +335,27 @@ "hint": "Scatta una foto del prodotto e l'AI cercherà di identificarlo", "identifying": "🤖 Identifico il prodotto...", "no_api_key": "⚠️ Chiave API Gemini non configurata.\nAggiungi GEMINI_API_KEY nel file .env sul server.", - "fields_filled": "✅ Campi compilati dall'AI" + "fields_filled": "✅ Campi compilati dall'AI", + "use_data": "✅ Usa dati AI", + "use_data_no_barcode": "✅ Usa dati AI (senza barcode)" }, "log": { - "title": "� Storico" + "title": "� Storico", + "type_added": "Aggiunto", + "type_waste": "Buttato", + "type_used": "Usato", + "type_bring": "Aggiunto a Bring!", + "undone_badge": "Annullato", + "undo_title": "Annulla questa operazione", + "load_error": "Errore nel caricamento log", + "empty": "Nessuna operazione registrata.", + "undo_action_remove": "rimozione di", + "undo_action_restore": "ripristino di", + "undo_confirm": "Annullare questa operazione?\n→ {action} {name}", + "undo_success": "↩ Operazione annullata per {name}", + "already_undone": "Operazione già annullata", + "too_old": "Non è possibile annullare operazioni più vecchie di 24 ore", + "undo_error": "Errore durante l'annullamento" }, "chat": { "title": "Gemini Chef", @@ -247,7 +366,12 @@ "suggestion_light": "🥗 Qualcosa di leggero", "suggestion_expiry": "⏰ Usa le scadenze", "clear": "Nuova conversazione", - "placeholder": "Chiedi qualcosa..." + "placeholder": "Chiedi qualcosa...", + "cleared": "Chat cancellata", + "suggestion_snack_text": "Cosa posso preparare per uno spuntino veloce?", + "suggestion_juice_text": "Fammi un succo o frullato con quello che ho", + "suggestion_light_text": "Ho fame ma voglio qualcosa di leggero", + "suggestion_expiry_text": "Cosa sta per scadere e come posso usarlo?" }, "cooking": { "close": "Chiudi", @@ -256,7 +380,13 @@ "replay": "🔊 Rileggi", "timer": "⏱️ {time} · Timer", "prev": "◀ Precedente", - "next": "Successivo ▶" + "next": "Successivo ▶", + "ingredient_used": "✔️ Scalato", + "ingredient_use_btn": "📦 Usa", + "ingredient_deduct_title": "Scala dalla dispensa", + "timer_expired_tts": "Timer {label} scaduto!", + "timer_warning_tts": "Attenzione! {label}: mancano 10 secondi!", + "recipe_done_tts": "Ricetta completata! Buon appetito!" }, "settings": { "title": "⚙️ Configurazione", @@ -401,12 +531,45 @@ "days": "{days} giorni", "expired_days": "Da {days}g", "expired_yesterday": "Da ieri", - "expired_today": "Oggi" + "expired_today": "Oggi", + "badge_today": "⚠️ Scade oggi!", + "badge_tomorrow": "⏰ Domani", + "badge_tomorrow_long": "⏰ Scade domani", + "badge_days": "⏰ {n} giorni", + "badge_expired_ago": "⚠️ Scaduto da {n}g", + "badge_expired": "⛔ Scaduto!", + "badge_stable": "✅ Stabile", + "badge_expiring_short": "⏰ Scade fra {n}gg", + "badge_ok_still": "✅ Ancora {n}gg", + "badge_expires_red": "🔴 Scade tra {n}g", + "badge_expires_yellow": "🟡 Scade tra {n}g", + "badge_expired_bare": "⚠️ Scaduto", + "badge_expires_warn": "⚠️ Scade tra {n}gg", + "badge_days_left": "⏳ ~{n}gg rimasti", + "days_approx": "~{n} giorni", + "weeks_approx": "~{n} settimane", + "months_approx": "~{n} mesi", + "years_approx": "~{n} anni", + "expired_today_long": "Scaduto oggi", + "expired_ago_long": "Scaduto da {n} giorni", + "expired_suffix": "— Scaduto!", + "days_compact": "{n}gg" }, "status": { "ok": "OK", "check": "Controlla", - "discard": "Buttare" + "discard": "Buttare", + "tip_freezer_ok": "In freezer: ancora sicuro (~{n}g di margine)", + "tip_freezer_check": "In freezer da molto, potrebbe aver perso qualità. Consumare presto", + "tip_freezer_danger": "In freezer da troppo tempo, rischio di bruciatura da gelo e degrado", + "tip_highRisk_check": "Scaduto da poco, controlla odore e aspetto prima di consumare", + "tip_highRisk_danger": "Prodotto deperibile scaduto: da buttare per sicurezza", + "tip_medRisk_check1": "Controlla aspetto e odore prima di consumare", + "tip_medRisk_check2": "Scaduto da un po', verificare bene prima dell'uso", + "tip_medRisk_danger": "Troppo tempo dalla scadenza, meglio buttare", + "tip_lowRisk_ok": "Prodotto a lunga conservazione, ancora sicuro da consumare", + "tip_lowRisk_check": "Scaduto da oltre un mese, controllare integrità confezione", + "tip_lowRisk_danger": "Scaduto da troppo tempo, meglio non rischiare" }, "toast": { "product_saved": "Prodotto salvato!", @@ -423,6 +586,7 @@ "finished_to_bring": "🛒 Prodotto finito → aggiunto a Bring!", "thrown_away": "🗑️ {name} buttato!", "thrown_away_partial": "🗑️ Buttato {qty} {unit} di {name}", + "product_finished_confirmed": "✅ Rimosso — riaggiungi quando ne ricompri", "appliance_added": "Elettrodomestico aggiunto", "item_added": "{name} aggiunto" }, @@ -439,18 +603,23 @@ "bring_add": "Errore nell'aggiunta a Bring!", "bring_connection": "Errore connessione Bring!", "identification": "Errore nell'identificazione", + "ai_quota": "Quota AI esaurita. Riprova tra qualche minuto.", "barcode_empty": "Inserisci un codice a barre", "barcode_format": "Il codice a barre deve contenere solo numeri (4-14 cifre)", "min_chars": "Scrivi almeno 2 caratteri", "not_in_inventory": "Prodotto non nell'inventario", "appliance_exists": "Elettrodomestico già presente", - "already_exists": "Già presente" + "already_exists": "Già presente", + "network_retry": "Errore di connessione. Riprova." }, "confirm": { - "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?" + "remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?", + "kiosk_exit": "Uscire dalla modalità kiosk?" }, "edit": { - "title": "Modifica {name}" + "title": "Modifica {name}", + "unknown_hint": "Inserisci il nome e le informazioni del prodotto", + "label_name": "🏷️ Nome prodotto" }, "screensaver": { "recipe_btn": "Ricette", @@ -485,11 +654,60 @@ "timeout": "Timeout: nessuna risposta dal gateway", "error_connect": "Impossibile connettersi al gateway", "tab": "Bilancia Smart", - "low_weight": "Peso < 10 g · inserisci manualmente\n(la lettura automatica richiede almeno 10 g)" + "low_weight": "Peso < 10 g · inserisci manualmente\n(la lettura automatica richiede almeno 10 g)", + "density_hint": "(densità {density} g/ml)", + "ml_hint": "(verrà convertito in ml)", + "weight_detected": "Peso rilevato — attendi 10s di stabilità…", + "weight_too_low": "Peso troppo basso — attendi…", + "stable": "✓ Stabile", + "auto_confirm": "✅ {val} {unit} — conferma automatica tra 5s (tocca per annullare)", + "cancelled_replace": "Annullato — rimetti l'ingrediente sulla bilancia per riprendere" }, "prediction": { "expected_qty": "Previsto: {expected} {unit}", "actual_qty": "Attuale: {actual} {unit}", "check_suggestion": "Verifica o pesa la quantità residua" + }, + "date": { + "today": "📅 Oggi", + "yesterday": "📅 Ieri" + }, + "scanner": { + "title_barcode": "🔖 Scansiona Barcode", + "barcode_hint": "Inquadra il codice a barre del prodotto", + "barcode_manual_placeholder": "O inserisci manualmente...", + "barcode_use_btn": "✅ Usa questo codice", + "ai_identifying": "🤖 Identifico il prodotto...", + "ai_analyzing": "🤖 Analisi AI in corso...", + "product_label_hint": "Inquadra l'etichetta del prodotto", + "expiry_label_hint": "Inquadra la data di scadenza stampata sul prodotto", + "capture_btn": "📸 Scatta", + "capture_photo_btn": "📸 Scatta Foto", + "retake_btn": "🔄 Riscatta", + "camera_error_hint": "Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.
Puoi inserire il barcode manualmente o usare l'identificazione AI.", + "no_barcode": "Senza barcode" + }, + "lowstock": { + "title": "⚠️ Sta per finire!", + "message": "{name} sta per finire — rimangono solo {qty}.", + "question": "Vuoi aggiungerlo alla lista della spesa?", + "yes": "🛒 Sì, aggiungi a Bring!", + "no": "No, per ora va bene" + }, + "move": { + "title": "📦 Spostare il resto?", + "question": "Vuoi spostare {thing} di {name} in un'altra posizione?", + "question_short": "Vuoi spostare {thing} in un'altra posizione?", + "thing_opened": "la confezione aperta", + "thing_rest": "il resto", + "stay_btn": "No, resta in {location}", + "moved_toast": "📦 Confezione aperta spostata in {location}", + "vacuum_restore": "🫙 Torna sotto vuoto" + }, + "nova": { + "1": "Non trasformato", + "2": "Ingrediente culinario", + "3": "Trasformato", + "4": "Ultra-trasformato" } -} \ No newline at end of file +}