diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e86a3a..6aad9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to EverShelf will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.9] - 2026-05-11 + +### Added +- **Category badge on inventory items** — Every product in the inventory now displays a macro-category badge (icon + label) next to the location badge. Badges showing `altro` are asynchronously refined via the new `guess_category` AI endpoint (Gemini + `data/category_ai_cache.json` cache) so the correct category appears automatically after the page loads. +- **Category search** — The inventory search bar now matches items by category. Typing "biscotti" returns every cookie/biscuit regardless of brand or exact name; the match uses both the direct category key and the translated label. +- **Brand map in `guessCategoryFromName`** — A fast-path brand table (Oreo, Ringo, Uno, Barilla, De Cecco, Galbani, Mutti, Lavazza, etc.) provides instant category resolution before any regex evaluation. +- **PHP `guess_category` endpoint** — New server-side action that calls Gemini to classify a product name into a local category key, with file-based caching (`data/category_ai_cache.json`). Returns `altro` immediately when no Gemini API key is configured. + +### Fixed +- **Duplicate banner alerts** — `loadBannerAlerts()` was occasionally enqueuing the same item multiple times when called concurrently. Fixed with a `_bannerLoading` re-entrancy guard and a `_queuedItemIds` Set that prevents any item from being pushed more than once per refresh cycle. +- **`mapToLocalCategory` with `en:dairies` / `en:dairies-and-eggs`** — The dairy regex was not matching OpenFoodFacts tags that use the `dairi` stem; extended to cover the full range of dairy tags. +- **`mapToLocalCategory` always returning `altro`** — When the input category was already `altro`, the function exited the direct-match loop before attempting any fallback, losing all name-based guesses. The loop now skips the `altro` key for the early-return and falls back to `guessCategoryFromName(productName)` at the end. +- **"Tonno all'olio" → condimenti** — `tonno\b` was matched after `olio\b` (condimenti) due to regex ordering. Moved the conserve block before the condimenti block so tuna products resolve correctly. + +### Security +- **AI function guards** — All Gemini-powered functions now check `_geminiAvailable` (JS) or the presence of `GEMINI_API_KEY` (PHP) before executing. Affected functions: `_refineCategoryBadgesAsync`, `fetchAllPrices`, `getShoppingPrice`. The PHP endpoint returns `{"success":false,"error":"no_api_key"}` instead of silently returning empty results, making the missing-key state explicit and diagnosable. + ## [1.7.8] - 2026-05-10 ### Added diff --git a/README.md b/README.md index 7d30491..192514b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ ## 🌍 Recent Updates +- **Category badges on inventory items** — Every product now shows its macro-category badge (icon + label) inline. Items that can't be classified locally are automatically refined via Gemini AI after the page loads, with server-side caching so the AI call only happens once per product. +- **Category search** — Typing a category name (e.g. "biscotti", "latticini") in the inventory search bar now returns all matching items across brands and exact names. +- **AI function guards** — All Gemini-powered features (price estimation, category classification, recipe suggestions, etc.) now explicitly check whether a Gemini API key is configured before making any AI call. The UI gracefully skips or shows a friendly message instead of silently failing. - **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab. - **Kiosk v1.7.0: OTA update system** — "Cerca aggiornamenti" button in Settings triggers a forced GitHub release check; new `installUpdate()` JS bridge calls Android `DownloadManager` directly (lockTask mode blocks external browser links); graceful degradation for older APKs with manual instructions. Automatic OTA check every 6 hours with native update banner. - **Kiosk: consistent APK signing** — Project keystore (`evershelf.jks`) committed to the repo; every build — local or CI — now produces an APK with the same signature, eliminating "APK incompatible / signature conflict" errors on OTA update. diff --git a/api/index.php b/api/index.php index 007e2d0..1793424 100644 --- a/api/index.php +++ b/api/index.php @@ -17,6 +17,7 @@ define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e4526 define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26'); define('GH_REPO', 'dadaloop82/EverShelf'); define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json'); +define('CATEGORY_CACHE_PATH', __DIR__ . '/../data/category_ai_cache.json'); /** Decode the XOR-obfuscated GitHub token at runtime. */ function _ghToken(): string { @@ -99,6 +100,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; } +// ── Ping / heartbeat — early response, no DB or rate-limit required ─────────── +if (($_GET['action'] ?? '') === 'ping') { + echo json_encode(['ok' => true, 'ts' => time()]); + exit; +} + // ===== RATE LIMITING ===== /** * Simple file-based rate limiter. @@ -455,6 +462,10 @@ try { getAllShoppingPrices($db); break; + case 'guess_category': + guessCategoryFromAI(); + break; + default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); @@ -7393,6 +7404,59 @@ PROMPT; return $out; } +/** +/** + * GET /api/?action=guess_category&name=... + * Returns the macro-category for a product name, using a file cache + Gemini AI fallback. + * Response: { category: string } + */ +function guessCategoryFromAI(): void { + $name = trim($_GET['name'] ?? ''); + if ($name === '') { echo json_encode(['category' => 'altro']); return; } + + // Load cache + $cache = []; + if (file_exists(CATEGORY_CACHE_PATH)) { + $cache = json_decode(file_get_contents(CATEGORY_CACHE_PATH), true) ?? []; + } + $key = md5(mb_strtolower($name)); + if (isset($cache[$key])) { echo json_encode(['category' => $cache[$key]]); return; } + + $apiKey = env('GEMINI_API_KEY', ''); + if ($apiKey === '') { echo json_encode(['category' => 'altro']); return; } + + $cats = 'latticini, carne, pesce, frutta, verdura, pasta, pane, surgelati, bevande, condimenti, snack, conserve, cereali, igiene, pulizia, altro'; + $prompt = "Sei un classificatore di prodotti alimentari e domestici italiani.\n" + . "Classifica il prodotto \"" . addslashes($name) . "\" in UNA di queste categorie esatte: $cats.\n" + . "Rispondi con SOLO la parola chiave della categoria, senza spiegazioni né punteggiatura aggiuntiva."; + + $payload = [ + 'contents' => [['parts' => [['text' => $prompt]]]], + 'generationConfig' => [ + 'temperature' => 0, + 'maxOutputTokens' => 20, + 'thinkingConfig' => ['thinkingBudget' => 0], + ], + ]; + + $result = callGeminiWithFallback($apiKey, $payload, 10); + $raw = strtolower(trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '')); + $raw = preg_replace('/[^a-z_ ]/', '', $raw); + $raw = trim($raw); + + $valid = ['latticini','carne','pesce','frutta','verdura','pasta','pane','surgelati', + 'bevande','condimenti','snack','conserve','cereali','igiene','pulizia','altro']; + $cat = in_array($raw, $valid, true) ? $raw : 'altro'; + + // Persist to cache + $cache[$key] = $cat; + @file_put_contents(CATEGORY_CACHE_PATH, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + echo json_encode(['category' => $cat]); +} + +// ───────────────────────────────────────────────────────────────────────────── + /** * GET /api/?action=get_shopping_price * POST body: { name, quantity, unit, default_quantity, package_unit, country, currency, lang, force_refresh } @@ -7417,6 +7481,12 @@ function getShoppingPrice(PDO $db): void { return; } + // Guard: price estimation requires Gemini API key + if (empty(env('GEMINI_API_KEY'))) { + echo json_encode(['success' => false, 'error' => 'no_api_key']); + return; + } + $cache = _loadPriceCache(); $key = _priceKey($name, $country); $now = time(); diff --git a/assets/css/style.css b/assets/css/style.css index 5abbbe2..efe870c 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -378,6 +378,51 @@ body { /* (scan active is defined above in .header-scan-btn:active) */ +/* ── Offline / server-unreachable banner ──────────────────────────────── */ +.offline-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: #dc2626; + color: #fff; + padding: 9px 16px; + font-size: 0.88rem; + font-weight: 600; + position: sticky; + top: 0; + z-index: 950; + box-shadow: 0 2px 8px rgba(220,38,38,0.35); +} +.offline-banner-icon { font-size: 1rem; line-height: 1; } +.offline-banner-text { flex: 1; text-align: center; } +.offline-banner-retry { + background: rgba(255,255,255,0.22); + border: 1px solid rgba(255,255,255,0.55); + color: #fff; + border-radius: 6px; + padding: 3px 11px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} +.offline-banner-retry:hover { background: rgba(255,255,255,0.38); } + +/* When server is offline, block interactions with the main content */ +body.server-offline .app-content { + opacity: 0.4; + pointer-events: none; + user-select: none; + transition: opacity 0.3s; +} +body.server-offline .bottom-nav { + opacity: 0.4; + pointer-events: none; + transition: opacity 0.3s; +} + /* Spesa mode banner */ .spesa-mode-banner { display: flex; @@ -1098,6 +1143,11 @@ body { color: #1d4ed8; } +.badge-category { + background: #f1f5f9; + color: #475569; +} + .badge-qty { background: #d1fae5; color: #047857; diff --git a/assets/js/app.js b/assets/js/app.js index 2e6cc86..2754bc6 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1215,15 +1215,14 @@ const URGENCY_BG = { // Map Open Food Facts categories to local categories function mapToLocalCategory(ofCategory, productName) { if (!ofCategory) { - // No category tag — try to guess from product name return guessCategoryFromName(productName || ''); } const cat = ofCategory.toLowerCase(); - // Direct match with our local keys + // Direct match with our local keys — but NOT 'altro': fall through to name guess for (const key of Object.keys(CATEGORY_ICONS)) { - if (cat === key) return key; + if (cat === key && key !== 'altro') return key; } - + // Handle specific Open Food Facts tags FIRST (before generic regex) // "plant-based-foods-and-beverages" is a catch-all — use product name to decide if (/plant-based-foods/.test(cat)) { @@ -1233,9 +1232,15 @@ function mapToLocalCategory(ofCategory, productName) { if (/^en:beverages/.test(cat)) return 'bevande'; // sweeteners = condimenti if (/sweetener|dolcific/.test(cat)) return 'condimenti'; - + // food-additives, cooking-helpers, flavourings = condimenti + if (/food-additive|cooking-helper|aromi|flavour/.test(cat)) return 'condimenti'; + // breakfasts = cereali + if (/breakfast/.test(cat)) return 'cereali'; + // dried-products = conserve + if (/dried-product/.test(cat)) return 'conserve'; + // Specific tag patterns - if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte|egg|uova|uovo|poultry-egg/.test(cat)) return 'latticini'; + if (/dairi|dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte\b|egg\b|uova\b|uovo\b|poultry-egg/.test(cat)) return 'latticini'; if (/meat|viande|carne|sausage|salum|prosciutt/.test(cat)) return 'carne'; if (/fish|poisson|pesce|seafood|tuna|tonno|salmone/.test(cat)) return 'pesce'; if (/fruit|frutta|juice|succo|apple|banana/.test(cat)) return 'frutta'; @@ -1245,12 +1250,15 @@ function mapToLocalCategory(ofCategory, productName) { if (/frozen|surgelé|surgel|gelat/.test(cat)) return 'surgelati'; if (/sauce|condiment|oil|olio|vinegar|aceto|mayo|ketchup|spice|salt|sugar|zuccher/.test(cat)) return 'condimenti'; if (/snack|chip|crisp|chocolate|cioccolat|candy|biscuit|cookie|wafer|merendine|patatine/.test(cat)) return 'snack'; - if (/conserve|canned|can|pelati|passata|preserve|jam|marmellat|miele|honey/.test(cat)) return 'conserve'; + if (/preserve|jam|marmellat|miele|honey|canned|pelati|passata/.test(cat)) return 'conserve'; if (/cereal|muesli|granola|oat|fiocchi/.test(cat)) return 'cereali'; if (/hygiene|soap|shampoo|igien|dentifricio|deodorant/.test(cat)) return 'igiene'; if (/clean|detergent|pulizia|detersiv/.test(cat)) return 'pulizia'; // Beverage check LAST (to avoid false matches on compound tags) if (/^(?!.*plant-based).*(beverage|drink|boisson|bevand|water|acqua|beer|birra|wine|vino|coffee|caffè|tea\b)/.test(cat)) return 'bevande'; + // Last resort: try product name before giving up + const nameGuess = guessCategoryFromName(productName || ''); + if (nameGuess !== 'altro') return nameGuess; return 'altro'; } @@ -1258,36 +1266,47 @@ function mapToLocalCategory(ofCategory, productName) { function guessCategoryFromName(name) { if (!name) return 'altro'; const n = name.toLowerCase(); + // ── Known Italian brand names → direct category (fast-path before regex) + // "Uno" only if it starts the name (Bahlsen biscuits, not the Italian word) + if (/^uno\b/.test(n)) return 'snack'; + const _brandRx = [ + [/\b(baiocchi|macine|tarallucci|tegolini|pavesini|plasmon|loacker|manner|digestive|oreo|hanuta|ringo|abbracci|gocciole|pan di stelle|oro saiwa|kinder|ferrero rocher|raffaello|bounty|twix|snickers|pringles|fonzies|tuc\b|ritz\b|mulino bianco|gran cereale|gocciole|saiwa|togo|principe|oro ciok|kit ?kat)\b/, 'snack'], + [/\b(barilla|de cecco|garofalo|la molisana|rummo|voiello|divella|agnesi|buitoni)\b/, 'pasta'], + [/\b(galbani|granarolo|yomo|danone|muller|müller|pr[eé]sident|santa lucia|jocca|fiorfiore)\b/, 'latticini'], + [/\b(mutti|cirio)\b/, 'conserve'], + [/\b(san pellegrino|levissima|ferrarelle|lete|nestea|lipton|nescaf[eé]|lavazza|illy\b|kimbo|segafredo)\b/, 'bevande'], + ]; + for (const [rx, cat] of _brandRx) { if (rx.test(n)) return cat; } // Pasta & Rice - if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette/.test(n)) return 'pasta'; + if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette|sedani|tortiglioni|calamarata|spaghettini|vermicelli/.test(n)) return 'pasta'; // Pane & Forno - if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli|pancarrè|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b/.test(n)) return 'pane'; + if (/pane\b|bauletto|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini\b|sandwich|taralli|pancarr[eè]|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b|pangrattato|pane grattugiato|pan.*carr[eè]/.test(n)) return 'pane'; // Latticini (before bevande to avoid latte→bevande) - if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie|camembert|emmental|asiago|feta\b|provola|caciotta|caprino/.test(n)) return 'latticini'; - // Conserve - if (/passata|pelati|pomodoro|pomodori|pomodorini|ciliegino|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive|tonno in scatola|sgombro in scatola|concentrato|brodo|dado|besciamella/.test(n)) return 'conserve'; - // Condimenti (include spezie, farine, zucchero) - if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa|paprika|curry|cannella|noce moscata|origano|rosmarino|timo|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido|maizena|semola|pesto|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco/.test(n)) return 'condimenti'; + if (/latte\b|yogurt|y[o]?gurt|yaourt|yougurt|yoghurt|formaggio|mozzarella|burro\b|panna\b|ricott|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie\b|camembert|emmental|asiago|feta\b|provola|caciotta|caprino|philadelphia|skyr|kefir|labneh/.test(n)) return 'latticini'; + // Conserve — controllo tonno\b PRIMA di condimenti (che ha olio\b) + if (/passata|pelati|pomodoro\b|pomodori|pomodorini|ciliegino|sugo\b|polpa di pomod|marmellata|miele\b|zagara|legumi|ceci\b|fagioli\b|lenticchie|olive\b|tonno\b|sgombro in scatola|concentrato|brodo\b|dado\b|besciamella|datterini|passato di/.test(n)) return 'conserve'; + // Condimenti (include spezie, farine, zucchero, aromi, lieviti) + if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina\b|maionese|ketchup|senape|salsa\b|paprika|curry\b|cannella|noce moscata|origano|rosmarino|timo\b|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido\b|maizena|semola|pesto\b|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco|aroma\b|aromi\b|arome\b|estratto.*vaniglia|estratto.*limone|polenta\b|semolino\b|cacao amaro|cacao.*polvere|purea|pure\b|pur[ée]e/.test(n)) return 'condimenti'; // Bevande (after latticini to avoid latte conflict) - if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino/.test(n)) return 'bevande'; + if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caff[eè]\b|kaffee|kafè|t[eè]\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino|ciobar|ovomaltine|zuppalatte|cioccolata.*calda|latte.*cioccolato/.test(n)) return 'bevande'; // Carne (include salumi) - if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola/.test(n)) return 'carne'; + if (/pollo\b|manzo|maiale|vitello|tacchino|prosciutto|salame\b|bresaola|mortadella|wurstel|speck\b|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola|schinken|scamorza affumicat|spianata/.test(n)) return 'carne'; // Pesce - if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi|alici|branzino|orata|sardine|trota|dentice|seppia|polpo/.test(n)) return 'pesce'; + if (/tonno\b|salmone|merluzzo|pesce\b|sgombro\b|gamberi|acciughe|baccal[aà]|vongole|cozze|calamari|surimi|alici|branzino|orata\b|sardine|trota|dentice|seppia|polpo|filetto.*pesce|pesce.*filetto/.test(n)) return 'pesce'; // Frutta - if (/mela|mele|banana|arancia|pera|fragola|uva\b|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|fico\b|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta'; + if (/mela\b|mele\b|banana|arancia|pera\b|fragola|uva\b|kiwi\b|limone|frutta\b|mandarino|clementina|pompelmo|avocado|mango\b|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta'; // Verdura - if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais|zucca|aglio|cetriolo|rapa|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura'; + if (/insalata|zucchina|zucchine|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais\b|zucca\b|aglio\b|cetriolo|rapa\b|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura'; // Surgelati - if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato/.test(n)) return 'surgelati'; + if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato|potato wedge|potato.*wedge/.test(n)) return 'surgelati'; // Snack & Dolci - if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini|torta|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramisù/.test(n)) return 'snack'; + if (/biscott|cioccolat|nutella|merendine\b|merendina|patatine|caramelle|wafer|cialda|cialdine|sfornatini|torta\b|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramis[uù]|cantucci|amaretti|savoiardi|pralin|confetti dolci|chicchi.*cacao|cacao.*chicchi|risofrolle|sfogliatine|ossi di morto|canestrelli|snack/.test(n)) return 'snack'; // Cereali - if (/cereali|muesli|fiocchi|granola|polenta|porridge|avena/.test(n)) return 'cereali'; + if (/cereali|muesli|fiocchi|granola|porridge|avena|mix energia|misto cereal|farro\b|orzo\b|quinoa/.test(n)) return 'cereali'; // Igiene personale if (/sapone|shampoo|dentifricio|deodorante|carta igienica|fazzoletti|cotton fioc|assorbente|rasoio|schiuma da barba|gel doccia|balsamo\b|lozione/.test(n)) return 'igiene'; // Pulizia casa - if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia'; + if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti\b|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia'; return 'altro'; } @@ -3492,6 +3511,7 @@ function _dismissNoExpiry(productId) { // === ALERT BANNER SYSTEM (replaces old review table) === let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction' let _bannerIndex = 0; +let _bannerLoading = false; // guard against concurrent calls let _bannerEditPending = false; // true when editing from banner → dismiss after save let _bannerRefreshTimer = null; // periodic refresh while on dashboard let _shoppingPollTimer = null; // periodic refresh while on shopping page (multi-client sync) @@ -3501,10 +3521,12 @@ let _shoppingPollTimer = null; // periodic refresh while on shopping page (mul * merge into a single banner queue and show the first item. */ async function loadBannerAlerts() { + if (_bannerLoading) return; + _bannerLoading = true; _bannerQueue = []; _bannerIndex = 0; const banner = document.getElementById('alert-banner'); - if (!banner) { console.warn('[Banner] #alert-banner not found'); return; } + if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; } try { const [invData, predData, anomalyData, finishedData] = await Promise.all([ @@ -3515,6 +3537,8 @@ async function loadBannerAlerts() { ]); const items = invData.inventory || []; const confirmed = getReviewConfirmed(); + // Track item IDs already queued to prevent the same item appearing in multiple types + const _queuedItemIds = new Set(); // 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 @@ -3547,6 +3571,7 @@ async function loadBannerAlerts() { // Skip items the freezer bonus still considers safe — no need to alarm the user if (getExpiredSafety(item, daysExpired).level === 'ok') return; _bannerQueue.push({ type: 'expired', data: { ...item, days_expired: daysExpired } }); + _queuedItemIds.add(item.id); }); // 2. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner) @@ -3562,6 +3587,7 @@ async function loadBannerAlerts() { }); items.forEach(item => { + if (_queuedItemIds.has(item.id)) return; // already in expired if (confirmed[item.id]) return; const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz']; const qty = parseFloat(item.quantity); @@ -3600,6 +3626,7 @@ async function loadBannerAlerts() { else if (isLow) warning = '⬇️ Troppo poco'; else warning = '⬆️ Troppo'; _bannerQueue.push({ type: 'review', data: { ...item, warning, _isLow: isLow } }); + _queuedItemIds.add(item.id); }); // 4. Consumption predictions that don't match actual quantity @@ -3628,6 +3655,7 @@ async function loadBannerAlerts() { const PERISHABLE_CATS = ['latticini','carne','pesce','salumi','fresco','verdura','frutta','surgelati', 'dairy','meat','fish','fresh','vegetables','fruit','frozen']; items.forEach(item => { + if (_queuedItemIds.has(item.id)) return; // already in expired or review if (item.expiry_date) return; // already has expiry if (parseFloat(item.quantity) <= 0) return; // no stock const pid = String(item.product_id || item.id); @@ -3651,6 +3679,8 @@ async function loadBannerAlerts() { } catch (e) { console.error('[Banner] loadBannerAlerts error:', e); + } finally { + _bannerLoading = false; } if (_bannerQueue.length > 0) { @@ -4370,7 +4400,10 @@ async function loadInventory() { } function renderInventoryItem(item) { - const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦'; + const catKey = mapToLocalCategory(item.category, item.name); + const catIcon = CATEGORY_ICONS[catKey] || '📦'; + const catLabel = t('categories.' + catKey) || catKey; + const catBadge = `${catIcon} ${catLabel}`; const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location }; const days = daysUntilExpiry(item.expiry_date); const isExpired = days < 0; @@ -4401,6 +4434,7 @@ function renderInventoryItem(item) { ${item.brand ? `