From 03142e2f7fe8039da77cd82c93763065cf294eb3 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 21 Apr 2026 12:03:16 +0000 Subject: [PATCH 01/32] fix: retry Gemini 429 with backoff, add recipe rate limit bucket (5/min) --- api/index.php | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/api/index.php b/api/index.php index d7aee5a..8176900 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']; 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; @@ -2283,18 +2288,33 @@ 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, - ]); + // Call Gemini with retry on 429 (transient rate limit) + $maxAttempts = 3; + $response = false; + $httpCode = 0; + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $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, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + if ($httpCode === 200) break; + + // Retry on 429 (rate limited) or 503 (transient server error) + if (($httpCode === 429 || $httpCode === 503) && $attempt < $maxAttempts) { + // Exponential backoff: 2s, 4s + sleep($attempt * 2); + continue; + } + break; + } if ($response === false || $httpCode !== 200) { $errDetail = ''; From 234cae14bcf20e8e72c92e4a798fb4c239bc0d9c Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 21 Apr 2026 12:12:04 +0000 Subject: [PATCH 02/32] perf: remove Gemini from bringSuggest and product selection - pure offline logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bringSuggestItems: now uses smart_shopping cache (already computed offline) instead of calling Gemini with full inventory prompt - aiSelectBestProduct: replaced Gemini call with token-scoring algorithm; scores by token overlap, first-anchor bonus, spec-variant matching, category mismatch penalty — 0ms vs ~1s per product search - Only truly necessary AI calls remain: photo expiry, photo identify, chat assistant, recipe generation --- api/index.php | 424 +++++++++++++++++++------------------------------- 1 file changed, 163 insertions(+), 261 deletions(-) diff --git a/api/index.php b/api/index.php index 8176900..69eea8b 100644 --- a/api/index.php +++ b/api/index.php @@ -3480,221 +3480,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) ===== @@ -3962,73 +3821,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 { From f4a62ef4965dc87b13d0714d263d08140b1b25bc Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 21 Apr 2026 12:34:54 +0000 Subject: [PATCH 03/32] feat: anomaly detection banner - notifica incongruenze inventario/transazioni - New API endpoint 'inventory_anomalies': detects items where stored qty differs from tx history by >20% AND >50 units (phantom qty or missing qty) - New API endpoint 'dismiss_anomaly': persists dismissal in anomaly_dismissed.json - Banner system: new 'anomaly' type shown in dashboard alert banner with 'Correggi' (opens edit) and 'Ok, ignora' (dismisses) buttons - CSS: banner-anomaly style (orange gradient) - Fix: lo zucchero azzerato (175g fantasma rimossi), aggiunto a Bring! --- api/index.php | 102 +++++++++++++++++++++++++++++++++++++++++++ assets/css/style.css | 6 +++ assets/js/app.js | 48 +++++++++++++++++++- index.html | 4 +- 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/api/index.php b/api/index.php index 69eea8b..06d98b1 100644 --- a/api/index.php +++ b/api/index.php @@ -202,6 +202,14 @@ try { getConsumptionPredictions($db); break; + case 'inventory_anomalies': + getInventoryAnomalies($db); + break; + + case 'dismiss_anomaly': + dismissInventoryAnomaly(); + break; + case 'recent_popular_products': recentPopularProducts($db); break; @@ -1261,6 +1269,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(); diff --git a/assets/css/style.css b/assets/css/style.css index 9efb0c8..9213ae4 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -4525,6 +4525,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; diff --git a/assets/js/app.js b/assets/js/app.js index bf49a5c..32226aa 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2354,9 +2354,10 @@ async function loadBannerAlerts() { if (!banner) { console.warn('[Banner] #alert-banner not found'); return; } try { - const [invData, predData] = await Promise.all([ + const [invData, predData, anomalyData] = 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: [] }; }), ]); const items = invData.inventory || []; const confirmed = getReviewConfirmed(); @@ -2401,6 +2402,13 @@ 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 }); + }); + // Sort by priority (highest first) _bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a)); @@ -2453,6 +2461,10 @@ 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; + } default: return 0; } @@ -2530,6 +2542,23 @@ function renderBannerItem() { btns += ``; } actionsEl.innerHTML = btns; + + } else if (entry.type === 'anomaly') { + const an = entry.data; + const diffAbs = Math.abs(an.diff); + const diffDisplay = `${diffAbs} ${an.unit}`; + const isPhantom = an.direction === 'phantom'; + banner.className = 'alert-banner banner-anomaly'; + iconEl.textContent = '🔍'; + titleEl.textContent = `Anomalia inventario: ${an.name}${an.brand ? ' (' + an.brand + ')' : ''}`; + if (isPhantom) { + detailEl.innerHTML = `Inventario: ${an.inv_qty}${an.unit} ma le transazioni ne giustificano solo ${an.expected_qty}${an.unit} (+${diffDisplay} fantasma)`; + } else { + detailEl.innerHTML = `Le transazioni indicano ${an.expected_qty}${an.unit} ma l'inventario ha solo ${an.inv_qty}${an.unit} (mancano ${diffDisplay})`; + } + let btns = ``; + btns += ``; + actionsEl.innerHTML = btns; } if (_bannerQueue.length > 1) { @@ -2585,6 +2614,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; diff --git a/index.html b/index.html index ad57a5e..3b9be70 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@ EverShelf - + @@ -1288,6 +1288,6 @@ - + From db033844d4f8a9e58b88b44e66d3a15caa5757b4 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Wed, 22 Apr 2026 11:38:47 +0000 Subject: [PATCH 04/32] Gemini: centralizza chiamate API in callGemini() con backoff intelligente - Aggiunto helper callGemini($url, $payload, $timeout): * Fino a 4 tentativi su 429 / 503 * Legge Retry-After header dalla risposta HTTP di Google * Legge retryDelay dal corpo JSON di errore (es. '10s', '30s') * Backoff default: 2s, 4s, 6s (sovrascitto da Google se specificato) - geminiReadExpiry(), geminiChat(), geminiIdentifyProduct(): rimosso curl diretto senza retry, ora usano callGemini() - generateRecipe(): rimosso vecchio loop manuale (3 tentativi, 2s/4s fissi), ora usa callGemini() che rispetta i delay suggeriti da Google - In caso di 429 finale restituisce il messaggio di errore da Google (non generico) --- api/index.php | 178 ++++++++++++++++++++++++++------------------------ 1 file changed, 94 insertions(+), 84 deletions(-) diff --git a/api/index.php b/api/index.php index 06d98b1..17c318d 100644 --- a/api/index.php +++ b/api/index.php @@ -1763,6 +1763,75 @@ 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, + ]; +} + function geminiReadExpiry(): void { $apiKey = env('GEMINI_API_KEY'); if (empty($apiKey)) { @@ -1803,27 +1872,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 = callGemini($url, $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 @@ -1973,26 +2031,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 = callGemini($url, $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']); @@ -2390,45 +2438,16 @@ PROMPT; ] ]; - // Call Gemini with retry on 429 (transient rate limit) - $maxAttempts = 3; - $response = false; - $httpCode = 0; - for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { - $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, - ]); - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + $result = callGemini($url, $payload, 60); + $httpCode = $result['http_code']; - if ($httpCode === 200) break; - - // Retry on 429 (rate limited) or 503 (transient server error) - if (($httpCode === 429 || $httpCode === 503) && $attempt < $maxAttempts) { - // Exponential backoff: 2s, 4s - sleep($attempt * 2); - continue; - } - break; - } - - 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 @@ -2688,25 +2707,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 = callGemini($url, $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); From 546d4afd59f79f1a9a03dc10ab69d09183bd8362 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Thu, 23 Apr 2026 15:16:50 +0000 Subject: [PATCH 05/32] feat: SSE streaming recipe generation with live agent feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add generateRecipeStream() endpoint with real-time SSE status events - Frontend generateRecipe() uses ReadableStream for live step updates - Fix gemini-2.5-flash thinking model: disable thinkingBudget, raise maxOutputTokens to 4096 - Passo 2 is pure PHP heuristic (zero extra AI calls) - Retry logic with live countdown on 429, fallback chain: 2.5-flash → 2.0-flash - Pass all ingredients when meal plan is active (no limits) - Add recipe-loading-msg element with CSS transition --- api/index.php | 518 ++++++++++++++++++++++++++++++++++++++++++- assets/css/style.css | 2 + assets/js/app.js | 79 +++++-- index.html | 2 +- 4 files changed, 580 insertions(+), 21 deletions(-) diff --git a/api/index.php b/api/index.php index 17c318d..c1f1390 100644 --- a/api/index.php +++ b/api/index.php @@ -66,7 +66,7 @@ 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']; + $recipeActions = ['generate_recipe', 'generate_recipe_stream']; if (in_array($action, $aiActions)) { $limit = 15; @@ -223,6 +223,10 @@ try { generateRecipe($db); break; + case 'generate_recipe_stream': + generateRecipeStream($db); + break; + case 'gemini_identify': geminiIdentifyProduct(); break; @@ -2654,6 +2658,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'); diff --git a/assets/css/style.css b/assets/css/style.css index 9213ae4..f87d1d5 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -3115,6 +3115,8 @@ body { margin-top: 16px; color: var(--text-muted); font-weight: 600; + transition: opacity 0.25s ease; + min-height: 1.4em; } .recipe-result { diff --git a/assets/js/app.js b/assets/js/app.js index 32226aa..7400292 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -9734,7 +9734,7 @@ async function generateRecipe() { const mealPlanType = mealPlanChipActive && (meal === 'pranzo' || meal === 'cena') ? (getTodayMealPlanType(meal) || null) : null; - + // Gather active options from checkboxes const options = []; const optMap = { @@ -9753,10 +9753,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, @@ -9766,34 +9767,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); diff --git a/index.html b/index.html index 3b9be70..0bb0a1c 100644 --- a/index.html +++ b/index.html @@ -1195,7 +1195,7 @@ -
- -

Sostituisce i nomi specifici con quelli generici (es. "Mortadella IGP" → "Affettato") negli item già presenti in Bring!.

- - -
From d1478245da6ab1ddf22556f546daad5b3922bae4 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Mon, 27 Apr 2026 17:37:01 +0000 Subject: [PATCH 27/32] fix: add 24 missing shopping_name aliases to Bring! catalog (100% coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All shopping_name values now resolve to a German Bring! catalog key: aroma / ingredienti spezie -> Zutaten & Gewürze bevande / liquore -> Getränke & Tabak camomilla -> Tee cioccolata calda -> Kakao cipolla -> Zwiebeln cracker / taralli / snack -> Snacks & Süsswaren farina integrale -> Mehl fette biscottate -> Toast filetto -> Fleisch muesli -> Corn Flakes panna da cucina -> Rahm passata / polpa pomodoro -> Pelati piatti pronti/purè/sfornat -> Fertig- & Tiefkühlprodukte salsa -> Zutaten & Gewürze succo -> Fruchtsaft vino -> Rotwein zucchero di canna -> Zucker --- data/bring_catalog.json | 645 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 645 insertions(+) create mode 100644 data/bring_catalog.json 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 From 608afb086d775abc48eba8cddde9ea5a9ff24887 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Mon, 27 Apr 2026 18:14:27 +0000 Subject: [PATCH 28/32] =?UTF-8?q?fix:=20bringMigrateNamesInternal=20?= =?UTF-8?q?=E2=80=94=20use=20PUT/remove=20and=20German=20catalog=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the migration function: 1. DELETE endpoint does not exist in Bring! API — must use PUT with 'remove' param (same as the remove-from-list flow elsewhere in the code) 2. Items were added using the Italian shopping_name as the 'purchase' field instead of the German catalog key via italianToBring(shoppingName). This created Italian/German duplicates (e.g. both 'Affettato' and 'Aufschnitt' in the list at the same time). Also add a pre-add duplicate check so existing catalog-key items are not double-added when the old specific item is removed. Manual cleanup run: removed 25 stale/duplicate items, added 8 correct German-key items, ran migration (1 more migrated). List is now clean. --- api/index.php | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/api/index.php b/api/index.php index cba4bcf..37a60f2 100644 --- a/api/index.php +++ b/api/index.php @@ -4240,7 +4240,11 @@ function bringMigrateNamesInternal(PDO $db, array $purchaseItems, string $listUU $shoppingName = $lookup[$key]['shopping_name']; $brand = $lookup[$key]['brand']; - // Already using the generic name → nothing to do + // 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; } @@ -4250,15 +4254,31 @@ function bringMigrateNamesInternal(PDO $db, array $purchaseItems, string $listUU $newSpec = $itName . ($brand ? " · {$brand}" : '') . ' — ' . $spec; } - // Remove old item, add with generic name - bringRequest('DELETE', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}/{$rawName}"); - $addBody = http_build_query([ - 'uuid' => $listUUID, - 'purchase' => $shoppingName, - 'specification' => $newSpec, - ]); - $result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $addBody); - if ($result !== false) { $migrated++; } else { $errors++; } + // 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]; From 1606cb3a90394826f2ca39d45b9c9b055ca0de58 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 28 Apr 2026 06:20:50 +0000 Subject: [PATCH 29/32] docs: add v1.4.0 CHANGELOG and README updates for all features since 1.3.0 --- CHANGELOG.md | 22 ++++++++++++++++++++++ README.md | 11 +++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b1b405..379c1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ 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.4.0] - 2026-04-28 + +### 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 7fe9ee2..6338366 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - **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 @@ -36,12 +36,12 @@ - **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-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 -- **DupliClick integration** — Online grocery ordering (Gruppo Poli) +- **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; supports browser Web Speech API, native Android TTS (kiosk), or a custom REST endpoint (Home Assistant, etc.) +- **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 - **Built-in timer** — Automatic timer suggestions based on recipe instructions - **Ingredient tracking** — Mark ingredients as used during cooking; leftover quantities prompt a "move to another location" flow @@ -65,8 +65,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 From 8a16307b392b2afa4e674499b8b895ddc4f3c179 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 28 Apr 2026 06:36:30 +0000 Subject: [PATCH 30/32] i18n: translate all hardcoded Italian strings in app.js Added 49 new translation keys to all 3 language files (IT/EN/DE) and wired every hardcoded Italian label/toast/hint in app.js to use the t() translation function. Sections covered: - scale: density_hint, ml_hint, weight_detected, weight_too_low, stable, auto_confirm - dashboard: banner review titles/details, prediction rate/days/ direction texts, finished-zero/expected/check, anomaly phantom/ghost titles and details - action: have_title, add_more_sub, use_qty_sub, throw_btn/sub, edit_sub - add: purchase_type_label, new_btn, existing_btn, remaining_label/hint/full/half - use: throw_title, throw_all, throw_qty_label/hint, throw_partial_btn - shopping: bring_badge, add_urgent_toast, migration_done, added_to_bring, added_to_bring_skip, all_on_bring, removed_sufficient (was a complex plural, now uses key) - toast: product_updated, thrown_away, thrown_away_partial - confirm: kiosk_exit - WEEK_DAYS array now uses t('days.*') keys --- assets/js/app.js | 124 +++++++++++++++++++++---------------------- translations/de.json | 63 +++++++++++++++++++--- translations/en.json | 63 +++++++++++++++++++--- translations/it.json | 63 +++++++++++++++++++--- 4 files changed, 230 insertions(+), 83 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index c363095..e09f7c0 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 = ''; @@ -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 @@ -2505,14 +2505,14 @@ function renderBannerItem() { iconEl.textContent = '⚠️'; let titleText, detailText; if (suspDq && !suspQty) { - titleText = `Confezione insolita: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; - detailText = `Hai impostato una confezione da ${item.default_quantity} ${item.package_unit} — la dimensione sembra molto alta. Controlla se è corretta o modifica.`; + 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 = `Quantità molto bassa: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; - detailText = `Hai solo ${qtyDisplay} in inventario — sembra poco, potrebbe essere un errore di inserimento. Conferma se è corretto.`; + 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 = `Quantità insolitamente alta: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; - detailText = `Hai ${qtyDisplay} in inventario — la cifra sembra molto alta. Conferma se è corretto o correggi.`; + 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; @@ -2530,19 +2530,19 @@ function renderBannerItem() { const daysSince = parseInt(pred.days_since_restock) || 0; banner.className = 'alert-banner banner-prediction'; iconEl.textContent = '📊'; - titleEl.textContent = `Consumo anomalo: ${pred.name}${pred.brand ? ' (' + pred.brand + ')' : ''}`; + titleEl.textContent = `${t('dashboard.banner_prediction_title')}: ${pred.name}${pred.brand ? ' (' + pred.brand + ')' : ''}`; let rateText = ''; if (dailyRate > 0) { rateText = dailyRate >= 1 - ? `Media ~${Math.round(dailyRate)} ${pred.unit}/giorno` - : `Media ~${Math.round(dailyRate * 7)} ${pred.unit}/settimana`; + ? 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 ? ` — ${daysSince} giorni fa hai rifornito` : ''; + const timeText = daysSince > 0 ? ` — ${t('dashboard.banner_prediction_days_ago', { n: daysSince })}` : ''; let diffText; if (dir === 'more') { - diffText = `mi aspettavo ${pred.expected_qty} ${pred.unit}${timeText}, ne hai invece ${pred.actual_qty} ${pred.unit}. Hai aggiunto scorte senza registrarle?`; + diffText = t('dashboard.banner_prediction_more', { expected: pred.expected_qty, unit: pred.unit, time: timeText, actual: pred.actual_qty }); } else { - diffText = `mi aspettavo ${pred.expected_qty} ${pred.unit}${timeText}, ne hai solo ${pred.actual_qty} ${pred.unit}. Hai consumato di più del solito?`; + 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 = ``; @@ -2560,8 +2560,8 @@ function renderBannerItem() { ? ` …${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 ? ` Secondo le registrazioni dovresti averne ancora ${fin.expected_qty} ${fin.unit}.` : ''; - detailEl.innerHTML = `L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.${expectedText} Puoi controllare?`; + 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; @@ -2572,11 +2572,11 @@ function renderBannerItem() { banner.className = 'alert-banner banner-anomaly'; iconEl.textContent = '🔍'; if (isPhantom) { - titleEl.textContent = `${an.name} — hai più scorte del previsto`; - detailEl.innerHTML = `L'inventario segna ${an.inv_qty} ${an.unit}, ma in base alle entrate e uscite registrate ne dovresti avere solo ${an.expected_qty} ${an.unit}. Hai aggiunto scorte o corretto la quantità manualmente senza registrarlo?`; + 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} — hai meno scorte del previsto`; - detailEl.innerHTML = `In base alle operazioni registrate dovresti avere ${an.expected_qty} ${an.unit} di ${an.name}, ma l'inventario mostra solo ${an.inv_qty} ${an.unit}. Hai prelevato senza registrarlo?`; + 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 += ``; @@ -4521,7 +4521,7 @@ function showProductAction() { statusBar.innerHTML = `
- 📦 Ce l'hai già! + ${t('action.have_title')}
${totalStr} ${totalFrac ? `${totalFrac}` : ''} @@ -4534,19 +4534,19 @@ function showProductAction() { btnsContainer.innerHTML = ` `; // Secondary: catalog edit link below the buttons (one instance only) @@ -4565,7 +4565,7 @@ function showProductAction() { btnsContainer.innerHTML = ` `; // Remove catalog-edit link if left over from a previous product @@ -4715,7 +4715,7 @@ function editActionInventoryItem(inventoryId) {
- +
@@ -4723,7 +4723,7 @@ function editActionInventoryItem(inventoryId) {
- + @@ -4831,7 +4831,7 @@ function showThrowForm() { document.getElementById('modal-content').innerHTML = `
@@ -4849,9 +4849,9 @@ function showThrowForm() {
-
oppure specifica la quantità:
+
${t('use.throw_qty_hint')}
@@ -4863,7 +4863,7 @@ function showThrowForm() {
- +
@@ -4871,7 +4871,7 @@ function showThrowForm() {
`; @@ -4897,7 +4897,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'); @@ -4922,7 +4922,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'); @@ -4971,7 +4971,7 @@ 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 { @@ -5074,13 +5074,13 @@ function showAddForm() { window._addBaseExpiryDays = estimatedDays; expirySection.innerHTML = ` - +
@@ -5323,12 +5323,12 @@ function selectPurchaseType(btn, type) {

Inserisci la data di scadenza o scansionala

- -

Quanto è rimasto approssimativamente?

+ +

${t('add.remaining_hint')}

- + - +
@@ -5702,7 +5702,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'); @@ -5970,7 +5970,7 @@ function showLowStockBringPrompt(result, afterCallback) { 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; } @@ -6962,7 +6962,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(); } @@ -7066,7 +7066,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(); } @@ -7405,7 +7405,7 @@ function renderSmartItem(item) { ${u.icon} ${u.label} ${freqBadge}${predBadge}${expiryBadge} ${item.is_opened ? '📭 Aperto' : ''} - ${item.on_bring ? '🛒 Già su Bring!' : ''} + ${item.on_bring ? `${t('shopping.bring_badge')}` : ''}
@@ -7423,7 +7423,7 @@ async function migrateBringNames(btn) { try { const data = await api('bring_migrate_names', {}, 'POST', {}); if (data.success) { - const msg = `✅ ${data.migrated} aggiornati, ${data.skipped} già ok${data.errors ? `, ${data.errors} errori` : ''}`; + 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'); @@ -7474,8 +7474,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(); @@ -8491,7 +8491,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. */ diff --git a/translations/de.json b/translations/de.json index 7946396..14cc40b 100644 --- a/translations/de.json +++ b/translations/de.json @@ -109,7 +109,25 @@ "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_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?" }, "inventory": { "title": "Vorrat", @@ -140,7 +158,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", @@ -150,7 +174,14 @@ "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" }, "use": { "title": "Verwenden / Verbrauchen", @@ -161,7 +192,12 @@ "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" }, "product": { "title_new": "Neues Produkt", @@ -231,7 +267,13 @@ "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!" }, "ai": { "title": "🤖 KI-Identifikation", @@ -455,7 +497,8 @@ "already_exists": "Bereits vorhanden" }, "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" @@ -493,7 +536,13 @@ "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)" }, "prediction": { "expected_qty": "Erwartet: {expected} {unit}", diff --git a/translations/en.json b/translations/en.json index d0db7ba..f213041 100644 --- a/translations/en.json +++ b/translations/en.json @@ -109,7 +109,25 @@ "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_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?" }, "inventory": { "title": "Pantry", @@ -140,7 +158,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", @@ -150,7 +174,14 @@ "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" }, "use": { "title": "Use / Consume", @@ -161,7 +192,12 @@ "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" }, "product": { "title_new": "New Product", @@ -231,7 +267,13 @@ "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!" }, "ai": { "title": "🤖 AI Identification", @@ -455,7 +497,8 @@ "already_exists": "Already exists" }, "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}" @@ -493,7 +536,13 @@ "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)" }, "prediction": { "expected_qty": "Expected: {expected} {unit}", diff --git a/translations/it.json b/translations/it.json index 942776b..57a2d78 100644 --- a/translations/it.json +++ b/translations/it.json @@ -109,7 +109,25 @@ "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_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?" }, "inventory": { "title": "Dispensa", @@ -140,7 +158,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", @@ -150,7 +174,14 @@ "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à" }, "use": { "title": "Usa / Consuma", @@ -161,7 +192,12 @@ "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à" }, "product": { "title_new": "Nuovo Prodotto", @@ -231,7 +267,13 @@ "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!" }, "ai": { "title": "🤖 Identificazione AI", @@ -455,7 +497,8 @@ "already_exists": "Già presente" }, "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}" @@ -493,7 +536,13 @@ "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)" }, "prediction": { "expected_qty": "Previsto: {expected} {unit}", From c3b19a6c48be237c5f560112f81745b30cd6b7c5 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 28 Apr 2026 12:46:00 +0000 Subject: [PATCH 31/32] feat: expired banner for opened products, AI model fallback, TTS cooking improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Banner: detect expired opened-products via effective shelf-life (opened_at + estimateOpenedExpiryDays), not just raw expiry_date — fixes Fagioli/Panna case - Banner: expired items show safety tip inline; danger-level items (fridge dairy, meat, fish) get red banner + 'L'ho buttato' as primary button, 'Usa comunque' demoted to grey; safety-ok/warning items keep original button order - Banner: anomaly dismiss button now shows current inventory qty ('La quantità è giusta (2 pz)') so the action is unambiguous - AI: add callGeminiWithFallback() helper — tries gemini-2.5-flash first (separate quota), falls back to gemini-2.0-flash; applied to all endpoints (expiry, chat, identify, recipe non-streaming, shopping name classifier) - AI: show friendly 'Quota AI esaurita' message instead of raw Gemini error string - Cooking TTS: fix auto-speak broken since 'auto-speak removed' comment — each step is now read automatically on navigate and on first step when entering cooking mode - Cooking TTS: remove incorrect s.tts_enabled gate — _cookingTTS toggle is the only gate; browser Web Speech API used by default without requiring Settings config - Cooking TTS: timer fires '10 secondi rimanenti' warning at T-10s - Cooking TTS: announce recipe completion ('Buon appetito!') on last step confirm - i18n: add timer_warning_tts, recipe_done_tts, error.ai_quota keys (IT/EN/DE) - CSS: add banner-expired-danger, banner-safety-* styles for unsafe expired items --- README.md | 15 +- api/index.php | 37 +-- assets/css/style.css | 33 +++ assets/js/app.js | 578 ++++++++++++++++++++++++------------------- translations/de.json | 199 +++++++++++++-- translations/en.json | 199 +++++++++++++-- translations/it.json | 199 +++++++++++++-- 7 files changed, 925 insertions(+), 335 deletions(-) diff --git a/README.md b/README.md index 6338366..2cbbd41 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ ### 📦 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 @@ -30,6 +30,7 @@ - **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 @@ -41,14 +42,18 @@ ### 🍳 Cooking Mode - **Step-by-step guidance** — Follow recipes with a hands-free cooking interface -- **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 +- **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; 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 diff --git a/api/index.php b/api/index.php index 37a60f2..9b30d2a 100644 --- a/api/index.php +++ b/api/index.php @@ -1972,6 +1972,23 @@ function callGemini(string $url, array $payload, int $timeout = 60): array { ]; } +/** + * 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)) { @@ -1988,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' => [ [ @@ -2012,7 +2027,7 @@ function geminiReadExpiry(): void { ] ]; - $result = callGemini($url, $payload, 30); + $result = callGeminiWithFallback($apiKey, $payload, 30); $httpCode = $result['http_code']; if ($httpCode !== 200) { @@ -2161,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' => [ @@ -2171,7 +2184,7 @@ PROMPT; ] ]; - $result = callGemini($url, $payload, 60); + $result = callGeminiWithFallback($apiKey, $payload, 60); $httpCode = $result['http_code']; if ($httpCode !== 200) { @@ -2562,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' => [ [ @@ -2578,7 +2589,7 @@ PROMPT; ] ]; - $result = callGemini($url, $payload, 60); + $result = callGeminiWithFallback($apiKey, $payload, 60); $httpCode = $result['http_code']; if ($httpCode !== 200) { @@ -3337,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' => [ [ @@ -3359,7 +3368,7 @@ PROMPT; ] ]; - $result = callGemini($url, $payload, 30); + $result = callGeminiWithFallback($apiKey, $payload, 30); $httpCode = $result['http_code']; if ($httpCode !== 200) { @@ -3638,8 +3647,6 @@ function _geminiClassifyProduct(string $name, string $brand, string $category): $cacheKey = md5(mb_strtolower($name . '|' . $brand)); if (isset($cache[$cacheKey])) return $cache[$cacheKey]; - $url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}"; - // Build catalog list so Gemini picks an existing Bring! entry when possible $catalog = bringCatalog(); $catalogList = implode(', ', array_slice(array_values($catalog['de2it']), 0, 200)); @@ -3668,7 +3675,7 @@ PROMPT; 'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 16], ]; - $result = callGemini($url, $payload, 15); + $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'] ?? ''); diff --git a/assets/css/style.css b/assets/css/style.css index 31003d5..60f57dd 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -5679,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 e09f7c0..fcb6f4c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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)); } /** @@ -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'; } @@ -2364,12 +2364,34 @@ async function loadBannerAlerts() { 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. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner) @@ -2483,15 +2505,26 @@ 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 ? 'Scaduto oggi' - : `Scaduto da ${item.days_expired} ${item.days_expired === 1 ? 'giorno' : 'giorni'}`; - 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 = `${item.name}${item.brand ? ' (' + item.brand + ')' : ''} — Scaduto!`; - detailEl.innerHTML = `${daysText} · hai ancora ${qtyDisplay}. Usalo subito o buttalo.`; - let 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; @@ -2579,7 +2612,7 @@ function renderBannerItem() { 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 += ``; + btns += ``; actionsEl.innerHTML = btns; } @@ -2844,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); } @@ -3017,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 `
@@ -3054,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); @@ -3174,27 +3207,27 @@ function showItemDetail(inventoryId, productId) {
@@ -3259,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'); @@ -3310,7 +3343,7 @@ function editInventoryItem(id) {
- +
@@ -3341,7 +3374,7 @@ function editInventoryItem(id) {
- +
${Object.entries(LOCATIONS).map(([k, v]) => `
- +
- + `; document.getElementById('modal-overlay').style.display = 'flex'; @@ -3421,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; } @@ -3489,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')}

`; } } @@ -3958,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); @@ -4034,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); @@ -4230,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'; @@ -4323,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); @@ -4354,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] : ''}
`; } @@ -4438,7 +4469,7 @@ function showProductAction() { ${isUnknown ? '

Inserisci il nome e le informazioni del prodotto

' : ''}
- +
@@ -4452,7 +4483,7 @@ function showProductAction() { ${categoryOptions}
- +
`; @@ -4507,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 ? ' 🫙' : ''; @@ -4683,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 ` + @@ -4800,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'); @@ -4975,7 +5006,7 @@ async function saveEditedProductInfo() { // 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); @@ -5085,14 +5116,14 @@ function showAddForm() {
- 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')}

@@ -5137,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)'; @@ -5145,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); } @@ -5167,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; } @@ -5292,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; @@ -5318,7 +5349,7 @@ function selectPurchaseType(btn, type) {
- +

Inserisci la data di scadenza o scansionala

@@ -5450,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) { @@ -5574,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'; } @@ -5978,13 +6009,15 @@ function showLowStockBringPrompt(result, afterCallback) { // 2. In smart shopping predictions? 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}
`; } @@ -6002,14 +6035,14 @@ function showLowStockBringPrompt(result, afterCallback) {
-

${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?

+

${t('lowstock.question')}

`; @@ -6086,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'; @@ -6121,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'); @@ -6159,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')); @@ -6205,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); } @@ -6299,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]; @@ -6310,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; } @@ -6347,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 += `
`; @@ -6397,8 +6433,8 @@ async function analyzeWithAI() { } catch (err) { console.error('AI identify error:', err); - resultDiv.innerHTML = `

❌ Errore di connessione

- `; + resultDiv.innerHTML = `

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

+ `; } } @@ -6525,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); @@ -6553,13 +6589,13 @@ async function captureForAIFormFill() {
-

Inquadra l'etichetta del prodotto

+

${t('scanner.product_label_hint')}

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

⚠️ Impossibile accedere alla fotocamera

`; + `

${t('error.camera')}

`; } } @@ -6632,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; } @@ -6660,7 +6701,7 @@ async function _pfAiAnalyze(base64) { html += ``; } - html += ``; + html += ``; resultEl.innerHTML = html; window._pfAiIdentified = id; @@ -6670,7 +6711,7 @@ async function _pfAiAnalyze(base64) { statusEl.style.display = 'none'; resultEl.style.display = 'block'; resultEl.innerHTML = `

❌ Errore di connessione

- `; + `; } } @@ -6750,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 => { @@ -6845,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, @@ -6864,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() { @@ -6877,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(); } @@ -6889,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; @@ -7289,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 @@ -7327,10 +7368,10 @@ 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)] || '📦'; @@ -7367,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 ` @@ -7404,7 +7445,7 @@ function renderSmartItem(item) {
${u.icon} ${u.label} ${freqBadge}${predBadge}${expiryBadge} - ${item.is_opened ? '📭 Aperto' : ''} + ${item.is_opened ? `${t('inventory.opened_badge')}` : ''} ${item.on_bring ? `${t('shopping.bring_badge')}` : ''}
@@ -7643,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')}
`; } } @@ -7698,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) @@ -7804,7 +7845,7 @@ async function renderShoppingItems() { } else if (priceData && priceData.searched && !priceData.product) { detailHtml = `
Non trovato
`; spesaBar = `
- +
`; } else { spesaBar = `
@@ -8169,7 +8210,7 @@ async function scanExpiryWithAI() { // Create modal for camera capture document.getElementById('modal-content').innerHTML = `
@@ -8181,14 +8222,14 @@ async function scanExpiryWithAI() { -

Inquadra la data di scadenza stampata sul prodotto

+

${t('scanner.expiry_label_hint')}

- - + +
`; @@ -8269,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 @@ -8291,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')}

+ `; } } @@ -8310,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) { @@ -8364,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}
`; @@ -8378,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 += `
`; }); @@ -8430,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) { @@ -8449,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'); } } @@ -8722,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)}
`; @@ -9068,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]) { @@ -9093,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; @@ -9113,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} @@ -9201,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 { @@ -9264,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'; @@ -9350,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) { @@ -9398,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 */ } } @@ -9693,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); @@ -9712,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) { @@ -9809,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) { @@ -10131,7 +10192,7 @@ async function sendChatMessage() { } } catch(err) { typingEl.remove(); - appendChatBubble('gemini', '⚠️ Errore di connessione'); + appendChatBubble('gemini', '⚠️ ' + t('error.connection')); } btn.disabled = false; @@ -10204,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() { @@ -10266,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(); @@ -10744,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/translations/de.json b/translations/de.json index 14cc40b..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", @@ -97,10 +99,10 @@ "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": "Passt so", + "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", @@ -127,7 +129,11 @@ "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?" + "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", @@ -136,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", @@ -181,7 +199,14 @@ "remaining_label": "📦 Verbleibende Menge", "remaining_hint": "Ungefähr wie viel ist noch übrig?", "remaining_full": "🟢 Voll", - "remaining_half": "🟠 Halb" + "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", @@ -197,7 +222,13 @@ "throw_all": "🗑️ ALLES entsorgen ({qty})", "throw_qty_label": "Wie viel wegwerfen?", "throw_qty_hint": "oder Menge angeben:", - "throw_partial_btn": "🗑️ Diese Menge entsorgen" + "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", @@ -229,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", @@ -273,7 +306,27 @@ "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!" + "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", @@ -282,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", @@ -296,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", @@ -305,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", @@ -450,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!", @@ -489,19 +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?", "kiosk_exit": "Kioskmodus verlassen?" }, "edit": { - "title": "{name} bearbeiten" + "title": "{name} bearbeiten", + "unknown_hint": "Produktname und Informationen eingeben", + "label_name": "🏷️ Produktname" }, "screensaver": { "recipe_btn": "Rezepte", @@ -542,11 +660,54 @@ "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)" + "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 f213041..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", @@ -97,10 +99,10 @@ "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": "Looks fine", + "banner_anomaly_action_dismiss": "Quantity is correct", "banner_expiring_title": "Expiring soon", "banner_expiring_today": "Expires today!", "banner_expiring_tomorrow": "Expires tomorrow", @@ -127,7 +129,11 @@ "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?" + "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", @@ -136,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", @@ -181,7 +199,14 @@ "remaining_label": "📦 Remaining quantity", "remaining_hint": "Approximately how much is left?", "remaining_full": "🟢 Full", - "remaining_half": "🟠 Half" + "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", @@ -197,7 +222,13 @@ "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" + "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", @@ -229,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", @@ -273,7 +306,27 @@ "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!" + "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", @@ -282,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", @@ -296,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", @@ -305,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", @@ -450,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!", @@ -489,19 +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?", "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", @@ -542,11 +660,54 @@ "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)" + "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 57a2d78..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", @@ -97,10 +99,10 @@ "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": "Va bene così", + "banner_anomaly_action_dismiss": "La quantità è giusta", "banner_expiring_title": "In scadenza", "banner_expiring_today": "Scade oggi!", "banner_expiring_tomorrow": "Scade domani", @@ -127,7 +129,11 @@ "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?" + "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", @@ -136,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", @@ -181,7 +199,14 @@ "remaining_label": "📦 Quantità rimasta", "remaining_hint": "Quanto è rimasto approssimativamente?", "remaining_full": "🟢 Pieno", - "remaining_half": "🟠 Metà" + "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", @@ -197,7 +222,13 @@ "throw_all": "🗑️ Butta TUTTO ({qty})", "throw_qty_label": "Quanto butti?", "throw_qty_hint": "oppure specifica la quantità:", - "throw_partial_btn": "🗑️ Butta questa 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", @@ -229,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", @@ -273,7 +306,27 @@ "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!" + "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", @@ -282,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", @@ -296,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", @@ -305,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", @@ -450,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!", @@ -489,19 +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?", "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", @@ -542,11 +660,54 @@ "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)" + "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 +} From 105c3298f33af9af3ab58391f17343a71e395d4c Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 28 Apr 2026 12:53:24 +0000 Subject: [PATCH 32/32] chore: bump version to 1.5.0 --- CHANGELOG.md | 17 ++++++++++++++++- manifest.json | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 379c1bc..2b7bb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,22 @@ 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.4.0] - 2026-04-28 +## [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 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",