diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b083f2..09eab30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,9 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + # Always use the built-in GITHUB_TOKEN for checkout (read-only fetch). + # WORKFLOW_PAT is only needed for the push step below. + token: ${{ github.token }} - name: Configure git bot identity run: | @@ -111,6 +113,15 @@ jobs: - name: Merge develop → main run: | + # ── ROOT CAUSE FIX ────────────────────────────────────────────────── + # actions/checkout writes an http.extraheader (AUTHORIZATION: basic …) + # that silently overrides any credentials embedded in git remote URLs. + # We must clear it BEFORE setting the remote URL with WORKFLOW_PAT, + # otherwise GITHUB_TOKEN is always used for the push and workflow-file + # changes are rejected. + # ──────────────────────────────────────────────────────────────────── + git config --local --unset-all http."https://github.com/".extraheader 2>/dev/null || true + LAST=$(git log --oneline -1 origin/develop) git checkout main git pull --ff-only origin main @@ -118,6 +129,26 @@ jobs: -m "chore: auto-merge develop → main Triggered by: $LAST" + + # ── PUSH STRATEGY ─────────────────────────────────────────────────── + # Priority 1: WORKFLOW_PAT (classic PAT, repo+workflow scopes) + # → can push workflow file changes; set as a repo secret. + # Priority 2: GITHUB_TOKEN fallback + # → cannot push workflow files; strip them from the merge commit. + # ──────────────────────────────────────────────────────────────────── + PUSH_TOKEN="${{ secrets.WORKFLOW_PAT }}" + if [ -z "$PUSH_TOKEN" ]; then + WF=$(git diff --name-only origin/main -- .github/workflows/ 2>/dev/null || echo "") + if [ -n "$WF" ]; then + echo "::warning::WORKFLOW_PAT not set — stripping workflow changes from merge commit:" + echo "$WF" + git checkout origin/main -- .github/workflows/ + git diff --cached --quiet || git commit --amend --no-edit + fi + PUSH_TOKEN="${{ github.token }}" + fi + + git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git" git push origin main # ── Auto-create GitHub Release on main ─────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 257ded7..f7b6fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. +## [1.7.25] - 2026-05-23 + +### Fixed +- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately after removing an item, so the purchased blocklist is populated before the next background refresh. `autoAddCriticalItems` now also respects the blocklist for depleted items (quantity = 0), preventing re-addition of just-bought products. +- **Barcode lookup false "not found"** — Added `_offFetchProduct()` PHP helper that tries up to three barcode candidates (given code, UPC-A → EAN-13 by prepending `0`, EAN-13 → UPC-A by stripping leading `0`) against two Open Food Facts locales (`it` and default), with one automatic retry on network error. UPCItemDB fallback also iterates the same candidates. Reduces false negatives on 12-digit US barcodes and region-specific EAN codes. +- **Partial throw from expired-items banner** — The "Butta" button in the dashboard banner previously discarded the entire inventory row with no confirmation. It now opens the existing throw modal (location picker + quantity input + "Butta tutto" option), consistent with the throw flow available from the scan → action page. + +### Added +- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family (matching first name token or shopping name, different product ID) already at home, so you avoid double-buying. + ## [1.7.24] - 2026-05-21 ### Fixed diff --git a/README.md b/README.md index 3520912..930c310 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ [](https://www.sqlite.org/) [](Dockerfile) [](translations/) -[](CHANGELOG.md) +[](CHANGELOG.md) [](https://github.com/dadaloop82/EverShelf/stargazers) [](https://github.com/dadaloop82/EverShelf/commits/main) [](https://github.com/dadaloop82/EverShelf/graphs/contributors) diff --git a/api/index.php b/api/index.php index 867fae2..2eebcdc 100644 --- a/api/index.php +++ b/api/index.php @@ -1442,6 +1442,87 @@ function searchBarcode(PDO $db): void { } } +function _offFetchProduct(string $barcode): ?array { + $fields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores'; + + // Try candidate barcodes: given barcode + EAN-13 (UPC-A → prepend 0) + $candidates = [$barcode]; + if (strlen($barcode) === 12 && ctype_digit($barcode)) { + $candidates[] = '0' . $barcode; + } + // Also try without leading zero if 13 digits starting with 0 + if (strlen($barcode) === 13 && $barcode[0] === '0') { + $candidates[] = substr($barcode, 1); + } + + // Locale preference: Italian first (better names), then world-neutral + $locales = ['lc=it', '']; + + foreach ($candidates as $bc) { + foreach ($locales as $lc) { + $lcParam = $lc ? "&{$lc}" : ''; + $url = "https://world.openfoodfacts.org/api/v2/product/{$bc}.json?fields={$fields}{$lcParam}"; + $ctx = stream_context_create(['http' => ['timeout' => 8, 'header' => "User-Agent: EverShelf/1.0\r\n"]]); + + $response = @file_get_contents($url, false, $ctx); + if ($response === false) { + // Network error: retry once after short delay + usleep(300000); // 0.3s + $response = @file_get_contents($url, false, $ctx); + } + if ($response === false) continue; + + $data = json_decode($response, true); + if (!isset($data['status']) || $data['status'] !== 1 || empty($data['product'])) continue; + + $p = $data['product']; + + // Prefer Italian name, fall back to generic / any locale + $name = ''; + foreach (['product_name_it', 'generic_name_it', 'product_name', 'generic_name'] as $f) { + if (!empty($p[$f])) { $name = $p[$f]; break; } + } + + // Non-Latin script fallback + if (!empty($name) && preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $name)) { + $latinName = ''; + foreach (['generic_name_it', 'generic_name', 'product_name_it', 'product_name'] as $f) { + if (!empty($p[$f]) && !preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $p[$f])) { + $latinName = $p[$f]; break; + } + } + if (empty($latinName)) $latinName = !empty($p['brands']) ? $p['brands'] : 'Prodotto sconosciuto'; + $name = $latinName; + } + + $ingredients = $p['ingredients_text_it'] ?? $p['ingredients_text'] ?? ''; + $category = $p['categories_tags'][0] ?? end($p['categories_hierarchy'] ?? []) ?? $p['categories'] ?? ''; + $allergens = ''; + if (!empty($p['allergens_tags'])) { + $allergens = implode(', ', array_map(fn($a) => str_replace('en:', '', $a), $p['allergens_tags'])); + } + + return [ + 'name' => $name, + 'brand' => $p['brands'] ?? '', + 'category' => $category, + 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', + 'quantity_info' => $p['quantity'] ?? '', + 'nutriscore' => $p['nutriscore_grade'] ?? '', + 'ingredients' => $ingredients, + 'allergens' => $allergens, + 'conservation' => $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? '', + 'origin' => $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? '', + 'nova_group' => $p['nova_group'] ?? '', + 'ecoscore' => $p['ecoscore_grade'] ?? '', + 'labels' => $p['labels'] ?? '', + 'stores' => $p['stores'] ?? '', + ]; + } + } + return null; +} + function lookupBarcode(): void { $barcode = $_GET['barcode'] ?? ''; if (empty($barcode)) { @@ -1449,141 +1530,37 @@ function lookupBarcode(): void { echo json_encode(['found' => false, 'error' => 'No barcode provided']); return; } - - // Try Open Food Facts API (Italian version first for better localized data) - $url = "https://world.openfoodfacts.org/api/v2/product/{$barcode}.json?fields=product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores&lc=it"; - $ctx = stream_context_create([ - 'http' => [ - 'timeout' => 10, - 'header' => "User-Agent: DispensaManager/1.0\r\n" - ] - ]); - - $response = @file_get_contents($url, false, $ctx); - if ($response === false) { - echo json_encode(['found' => false, 'source' => 'openfoodfacts', 'error' => 'API request failed']); + + // 1. Try Open Food Facts (multi-barcode, multi-locale, with auto-retry on network errors) + $offProduct = _offFetchProduct($barcode); + if ($offProduct !== null) { + echo json_encode(['found' => true, 'source' => 'openfoodfacts', 'product' => $offProduct]); return; } - - $data = json_decode($response, true); - if (isset($data['status']) && $data['status'] === 1 && !empty($data['product'])) { - $p = $data['product']; - - // Prefer Italian name, fall back to generic - // Also request localized name via abbreviated_product_name - $name = ''; - if (!empty($p['product_name_it'])) { - $name = $p['product_name_it']; - } elseif (!empty($p['generic_name_it'])) { - $name = $p['generic_name_it']; - } elseif (!empty($p['product_name'])) { - $name = $p['product_name']; - } elseif (!empty($p['generic_name'])) { - $name = $p['generic_name']; - } - - // If the name looks like it's in a non-Latin script (Arabic, Chinese, Thai, etc.) - // try to use a fallback from brands + generic category - if (!empty($name) && preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $name)) { - // Try other name fields that might be in Latin script - $latinName = ''; - foreach (['generic_name_it', 'generic_name', 'product_name_it', 'product_name'] as $field) { - if (!empty($p[$field]) && !preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $p[$field])) { - $latinName = $p[$field]; - break; - } - } - // If still no Latin name, construct from brand + category - if (empty($latinName)) { - $brand = $p['brands'] ?? ''; - $latinName = !empty($brand) ? $brand : 'Prodotto sconosciuto'; - } - $name = $latinName; - } - - // Get Italian ingredients, fall back to generic - $ingredients = ''; - if (!empty($p['ingredients_text_it'])) { - $ingredients = $p['ingredients_text_it']; - } elseif (!empty($p['ingredients_text'])) { - $ingredients = $p['ingredients_text']; - } - - // Category: prefer Italian categories_tags, fallback - $category = ''; - if (!empty($p['categories_tags'])) { - // Try to find an Italian-friendly category - $category = $p['categories_tags'][0] ?? ''; - } elseif (!empty($p['categories_hierarchy'])) { - $category = end($p['categories_hierarchy']); - } elseif (!empty($p['categories'])) { - $category = $p['categories']; - } - - // Allergens - $allergens = ''; - if (!empty($p['allergens_tags'])) { - $allergens = implode(', ', array_map(function($a) { - return str_replace('en:', '', $a); - }, $p['allergens_tags'])); - } - - // Conservation / storage - $conservation = $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? ''; - - // Origin - $origin = $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? ''; - - $result = [ - 'found' => true, - 'source' => 'openfoodfacts', - 'product' => [ - 'name' => $name, - 'brand' => $p['brands'] ?? '', - 'category' => $category, - 'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '', - 'quantity_info' => $p['quantity'] ?? '', - 'nutriscore' => $p['nutriscore_grade'] ?? '', - 'ingredients' => $ingredients, - 'allergens' => $allergens, - 'conservation' => $conservation, - 'origin' => $origin, - 'nova_group' => $p['nova_group'] ?? '', - 'ecoscore' => $p['ecoscore_grade'] ?? '', - 'labels' => $p['labels'] ?? '', - 'stores' => $p['stores'] ?? '', - ] - ]; - echo json_encode($result); - } else { - // Try UPC Item DB as fallback - $url2 = "https://api.upcitemdb.com/prod/trial/lookup?upc={$barcode}"; - $ctx2 = stream_context_create([ - 'http' => [ - 'timeout' => 10, - 'header' => "User-Agent: DispensaManager/1.0\r\n" - ] - ]); - $response2 = @file_get_contents($url2, false, $ctx2); - if ($response2 !== false) { - $data2 = json_decode($response2, true); - if (!empty($data2['items'][0])) { - $item = $data2['items'][0]; - echo json_encode([ - 'found' => true, - 'source' => 'upcitemdb', - 'product' => [ - 'name' => $item['title'] ?? '', - 'brand' => $item['brand'] ?? '', - 'category' => $item['category'] ?? '', - 'image_url' => $item['images'][0] ?? '', - ] - ]); + + // 2. Try UPC Item DB as fallback + $candidates = [$barcode]; + if (strlen($barcode) === 12 && ctype_digit($barcode)) $candidates[] = '0' . $barcode; + foreach ($candidates as $bc) { + $url2 = "https://api.upcitemdb.com/prod/trial/lookup?upc={$bc}"; + $ctx2 = stream_context_create(['http' => ['timeout' => 8, 'header' => "User-Agent: EverShelf/1.0\r\n"]]); + $r2 = @file_get_contents($url2, false, $ctx2); + if ($r2 !== false) { + $d2 = json_decode($r2, true); + if (!empty($d2['items'][0])) { + $item = $d2['items'][0]; + echo json_encode(['found' => true, 'source' => 'upcitemdb', 'product' => [ + 'name' => $item['title'] ?? '', + 'brand' => $item['brand'] ?? '', + 'category' => $item['category'] ?? '', + 'image_url' => $item['images'][0] ?? '', + ]]); return; } } - echo json_encode(['found' => false, 'source' => 'openfoodfacts']); } + + echo json_encode(['found' => false, 'source' => 'openfoodfacts']); } function saveProduct(PDO $db): void { @@ -9526,13 +9503,30 @@ function _calcEstimatedTotal(float $pricePerUnit, string $priceUnitLabel, float } elseif (($unit === 'conf' || $unit === 'pz') && $defQty > 0 && empty($pkgUnit)) { // pkgUnit not recorded in DB — for /kg prices assume defQty is in grams // (vast majority of grocery packages: pancetta 80g, formaggio 200g, etc.) - $weightKg = $qty * $defQty / 1000.0; + // GUARD: if defQty < 20 it is almost certainly a piece/unit count (e.g. "1 pz + // per purchase"), not a gram weight. Treating 1 as 1g would give a nonsense + // price (e.g. Peperoni defQty=1 → 0.001 kg → €0.003 displayed as €0.00). + // Skip the weight conversion for these; the item falls through to the + // countable path at the bottom (ppu × qty) which returns a rough estimate. + if ($defQty >= 20) { + $weightKg = $qty * $defQty / 1000.0; + } } elseif ($unit === 'g') { $weightKg = $qty / 1000.0; } elseif ($unit === 'kg') { $weightKg = $qty; } - if ($weightKg <= 0) return null; + if ($weightKg <= 0) { + // Two cases: + // A) defQty was 0 (no weight data at all) → "–" is more honest than a fake price. + // B) defQty was 1-19 (suspicious: the value was stored as a piece count, not grams; + // the assignment was intentionally skipped by the defQty<20 guard above). + // In case B, fall back to ppu × qty so the badge shows something rather than €0.00. + if (in_array($unit, ['pz', 'conf']) && $defQty > 0) { + return round($pricePerUnit * max(1.0, $qty), 2); + } + return null; + } return round($pricePerUnit * $weightKg, 2); } diff --git a/assets/css/style.css b/assets/css/style.css index 7cb35dd..bc9cdb3 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -5658,6 +5658,26 @@ body.cooking-mode-active .app-header { background: rgba(59, 130, 246, 0.15); } +/* Related stock hint (same generic family, different brand/product) */ +.action-related-stock-card { + background: #f0fdf4; + border: 1.5px solid #86efac; + border-radius: var(--radius); + padding: 10px 14px; + font-size: 0.82rem; + color: #166534; + margin-bottom: 12px; + line-height: 1.5; +} +.action-related-stock-card strong { color: #15803d; } +.related-stock-item { display: inline-block; margin-right: 8px; } +[data-theme="dark"] .action-related-stock-card { + background: rgba(21, 128, 61, 0.12); + border-color: #166534; + color: #86efac; +} +[data-theme="dark"] .action-related-stock-card strong { color: #4ade80; } + /* ===== ACTION BUTTONS GRID ===== */ .action-buttons-3col { display: grid; diff --git a/assets/js/app.js b/assets/js/app.js index 22dc829..06e45d9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -4551,7 +4551,7 @@ function isSuspiciousQty(qty, unit) { function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) { const n = parseFloat(defaultQty); if (!n || n <= 0) return false; - const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit; + const checkUnit = ((unit === 'conf' || unit === 'pz') && packageUnit) ? packageUnit : unit; const th = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz']; return n > th.max; } @@ -4750,24 +4750,30 @@ async function loadBannerAlerts() { }); // 7. Products with no expiry date set (and not permanently dismissed) + // Warn for ALL food/drink items — only skip igiene/pulizia (non-food). + // Items are capped at 8 per load (opened packages first) to avoid banner overflow. const noExpiryDismissed = _getNoExpiryDismissed(); - const PERISHABLE_CATS = ['latticini','carne','pesce','salumi','fresco','verdura','frutta','surgelati', - 'dairy','meat','fish','fresh','vegetables','fruit','frozen']; + const NON_FOOD_CATS = ['igiene', 'pulizia']; + const noExpiryItems = []; 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); if (noExpiryDismissed[pid]) return; // user said "no expiry needed" - // Only flag perishable-looking categories or items with opened_at - const cat = (item.category || '').toLowerCase(); - // Also infer category from name for items with missing/generic category const guessedCat = guessCategoryFromName(item.name || ''); - const perishableGuessed = ['latticini','carne','pesce','frutta','verdura','surgelati'].includes(guessedCat); - const likelyPerishable = item.opened_at || - PERISHABLE_CATS.some(c => cat.includes(c)) || - perishableGuessed; - if (!likelyPerishable) return; + const cat = (item.category || '').toLowerCase(); + // Skip non-food categories + if (NON_FOOD_CATS.includes(guessedCat) || + NON_FOOD_CATS.some(c => cat.includes(c))) return; + noExpiryItems.push(item); + }); + // Sort: opened packages first (more urgent), then alphabetically + noExpiryItems.sort((a, b) => { + if (!!a.opened_at !== !!b.opened_at) return a.opened_at ? -1 : 1; + return (a.name || '').localeCompare(b.name || ''); + }); + noExpiryItems.slice(0, 8).forEach(item => { _bannerQueue.push({ type: 'no_expiry', data: item }); }); @@ -5196,19 +5202,18 @@ function bannerThrowAway() { const entry = _bannerQueue[_bannerIndex]; if (!entry) return; const item = entry.data; - api('inventory_use', {}, 'POST', { - product_id: item.product_id, - quantity: item.quantity, - location: item.location, - use_all: true, - notes: 'Buttato' - }).then(res => { - if (res.success) { - showToast(t('toast.thrown_away', { name: item.name }), 'success'); - loadDashboard(); - } - }).catch(() => showToast(t('error.connection'), 'error')); - dismissBannerItem(); + // Populate currentProduct so the shared showThrowForm / throwAll / throwPartial work + currentProduct = { + id: item.product_id, + name: item.name, + brand: item.brand || '', + image_url: item.image_url || null, + category: item.category || '', + unit: item.unit || 'pz', + default_quantity: item.default_quantity || 0, + package_unit: item.package_unit || '' + }; + showThrowForm(); } async function bannerMarkVacuum() { @@ -7214,7 +7219,7 @@ function showProductAction() { } // === CHECK INVENTORY FOR THIS PRODUCT === - checkInventoryForProduct(currentProduct.id).then(inventoryItems => { + checkInventoryForProduct(currentProduct.id, currentProduct.name).then(({ items: inventoryItems, related: relatedItems }) => { _actionInventoryItems = inventoryItems; const statusBar = document.getElementById('action-inventory-status'); const btnsContainer = document.getElementById('action-buttons-container'); @@ -7302,6 +7307,31 @@ function showProductAction() { const orphan = document.getElementById('catalog-edit-link'); if (orphan) orphan.remove(); } + + // === RELATED STOCK (same generic family, different product/brand) === + const relatedEl = document.getElementById('action-related-stock'); + if (relatedEl) { + if (relatedItems.length > 0) { + // Group by product name+brand and sum quantities + const grouped = {}; + for (const ri of relatedItems) { + const key = ri.product_id; + if (!grouped[key]) grouped[key] = { item: ri, qty: 0 }; + grouped[key].qty += parseFloat(ri.quantity) || 0; + } + const parts = Object.values(grouped).map(({ item, qty }) => { + const qtyStr = formatQuantity(qty, item.unit, item.default_quantity, item.package_unit); + const locIcon = (LOCATIONS[item.location] || { icon: '📦' }).icon; + const label = item.name + (item.brand ? ` (${item.brand})` : ''); + return ``; + }).join(''); + relatedEl.innerHTML = `
`; + relatedEl.style.display = 'block'; + } else { + relatedEl.style.display = 'none'; + relatedEl.innerHTML = ''; + } + } }); // Update back button: go back to shopping if came from shopping list scan @@ -7331,12 +7361,27 @@ function showProductAction() { } // Check if product exists in inventory -async function checkInventoryForProduct(productId) { +async function checkInventoryForProduct(productId, productName) { try { const data = await api('inventory_list'); - return (data.inventory || []).filter(i => i.product_id == productId); + const all = data.inventory || []; + const exact = all.filter(i => i.product_id == productId); + + // Find inventory items from the same generic family (same shopping_name or first token) + const firstToken = (_nameTokens(productName || '')[0] || '').toLowerCase(); + const sNameFromExact = exact.length > 0 ? (exact[0].shopping_name || '').toLowerCase() : ''; + const matchToken = firstToken || sNameFromExact; + const related = matchToken ? all.filter(i => { + if (i.product_id == productId) return false; + const iFirst = (_nameTokens(i.name || '')[0] || '').toLowerCase(); + const iSName = (i.shopping_name || '').toLowerCase(); + return iFirst === matchToken || iSName === matchToken || + (sNameFromExact && (iFirst === sNameFromExact || iSName === sNameFromExact)); + }) : []; + + return { items: exact, related }; } catch(e) { - return []; + return { items: [], related: [] }; } } @@ -8815,12 +8860,12 @@ function selectUseLocation(btn, loc) { // ── PREFERRED USE LOCATION ─────────────────────────────────────────────── // After 3+ consistent choices from the same location for a product, // auto-selects it and hides the location picker (user can still tap "cambia"). -const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference +const _PREF_LOC_NEEDED = 1; // choices needed to confirm a preference // ── PREFERRED MOVE-AFTER-USE LOCATION ──────────────────────────────────── // Tracks where the user puts the remainder after using a product. // After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely. -const _PREF_MOVE_NEEDED = 2; +const _PREF_MOVE_NEEDED = 1; let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal function _getMoveLocHistory(productId, fromLoc) { @@ -9215,6 +9260,14 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu return; } + // If the product only exists at fromLoc (no other active locations), there is + // nothing to move — auto-stay silently without showing the modal. + const hasOtherLocs = (_useCurrentItems || []).some(i => i.location !== fromLoc); + if (!hasOtherLocs) { + _saveVacuumAndStay(openedId || 0); + return; + } + const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc); const locButtons = otherLocs.map(([k, v]) => `` @@ -9231,7 +9284,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu document.getElementById('modal-content').innerHTML = `${t('move.question').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest')).replace('{name}', `${escapeHtml(product.name)}`)}
@@ -10175,6 +10228,7 @@ async function confirmShoppingItemFound() { try { const r = await api('shopping_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID }); if (r.success) { + _markBringPurchased([name]); // prevent background sync from re-adding before barcode scan const idx = shoppingItems.findIndex(i => i.name.toLowerCase() === name.toLowerCase()); if (idx >= 0) shoppingItems.splice(idx, 1); showToast(t('shopping.item_removed').replace('{name}', name), 'success'); @@ -10283,8 +10337,9 @@ async function autoAddCriticalItems() { if (i.on_bring) return false; // For imminent items, do not honor local "purchased" blocklist too aggressively. // If they are predicted to finish within a week, keep Bring aligned automatically. - // Bypass blocklist for depleted items (current_qty=0) — they ran out and must be re-added - if (!imminentWeek && (i.current_qty ?? 0) > 0 && _isBringPurchased(i.name, i.urgency)) return false; + // Always honour the purchased blocklist so that items the user just removed from Bring! + // (i.e. bought them at the store) are not immediately re-added before they are scanned. + if (!imminentWeek && _isBringPurchased(i.name, i.urgency)) return false; if (i.urgency === 'critical') return true; if (i.urgency === 'high') return true; if (i.urgency === 'medium' && (i.days_left ?? 999) <= 7 && (i.uses_per_month || 0) >= 3) return true; @@ -10591,20 +10646,13 @@ async function cleanupObsoleteBringItems() { localStorage.setItem('_bringCleanupTs', String(Date.now())); if (!shoppingItems.length || !smartShoppingItems.length) return; - // Detect items added by the app vs manually by the user. - // Items added by the app always have urgency markers in their spec (⚡ / 🟠 / 🛒). - // This detection works across ALL clients — no localStorage dependency. - const APP_SPEC_MARKERS = ['⚡', '🟠', '🛒']; - const isAppAdded = (item) => { - const spec = item.specification || ''; - // Also trust the legacy localStorage list as secondary signal - const autoAdded = _getAutoAddedBring(); - const nameLow = item.name.toLowerCase(); - const hasMarker = APP_SPEC_MARKERS.some(m => spec.includes(m)); - const inLegacyMap = !!(autoAdded[nameLow] || - Object.keys(autoAdded).some(k => _nameTokens(k)[0] === (_nameTokens(item.name)[0] || ''))); - return hasMarker || inLegacyMap; - }; + // Detect items added automatically by the app (via autoAddCriticalItems). + // We rely ONLY on the explicit _autoAddedBring registry (exact name match). + // Do NOT use spec markers: autoSyncUrgencySpecs stamps urgency markers (⚡/🟠/🛒) + // on ALL matched items regardless of who added them, making marker-based detection + // unreliable and causing accidental removal of user-added items. + const _autoAdded = _getAutoAddedBring(); + const isAppAdded = (item) => !!(_autoAdded[item.name.toLowerCase()]); // Build shopping_name family → total stock from smart_shopping (server already computed this) // If smart says a family is NOT needed, it already excluded them. @@ -10649,11 +10697,15 @@ async function cleanupObsoleteBringItems() { } const toRemove = []; + const _pinned = _pinnedBringCache || {}; for (const item of shoppingItems) { const nameLower = item.name.toLowerCase(); const itemToks = _nameTokens(item.name); const itemFirst = itemToks[0]; + // Never remove items explicitly pinned by the user + if (_pinned[nameLower]) continue; + // Only remove items the app put there if (!isAppAdded(item)) continue; @@ -11587,6 +11639,7 @@ async function removeBringItem(idx) { listUUID: shoppingListUUID }); if (data.success) { + _markBringPurchased([item.name]); // prevent background sync from re-adding before barcode scan shoppingItems.splice(idx, 1); renderShoppingItems(); showToast(t('toast.removed_from_list_short'), 'success'); @@ -14646,6 +14699,29 @@ function saveChatHistory() { // ===== SCREENSAVER & INACTIVITY AUTO-REFRESH ===== let _inactivityTimer = null; let _screensaverActive = false; + +// ── Auto-home: always-on 2-minute idle return to dashboard ── +let _autoHomeTimer = null; +const AUTO_HOME_MS = 2 * 60 * 1000; // 2 minutes + +function _resetAutoHomeTimer() { + clearTimeout(_autoHomeTimer); + _autoHomeTimer = setTimeout(_triggerAutoHome, AUTO_HOME_MS); +} + +function _cancelAutoHomeTimer() { + clearTimeout(_autoHomeTimer); + _autoHomeTimer = null; +} + +function _triggerAutoHome() { + if (_screensaverActive) return; + if (document.body.classList.contains('cooking-mode-active')) return; + if (_currentPageId === 'dashboard') return; + const modal = document.getElementById('modal-overlay'); + if (modal && modal.style.display === 'flex') return; + showPage('dashboard'); +} let _screensaverClockInterval = null; let _screensaverFactInterval = null; let _screensaverData = null; // cached data for fact generation @@ -14665,6 +14741,7 @@ function resetInactivityTimer() { function activateScreensaver() { if (_screensaverActive) return; if (document.body.classList.contains('cooking-mode-active')) return; + _cancelAutoHomeTimer(); _screensaverActive = true; const overlay = document.getElementById('screensaver'); overlay.style.display = 'flex'; @@ -14756,6 +14833,7 @@ function dismissScreensaver(targetPage) { refreshCurrentPage(); } resetInactivityTimer(); + _resetAutoHomeTimer(); }, 400); } @@ -15347,16 +15425,22 @@ function initInactivityWatcher() { const events = ['pointerdown', 'pointermove', 'keydown', 'scroll', 'touchstart']; events.forEach(evt => { document.addEventListener(evt, () => { - if (!getSettings().screensaver_enabled) return; if (_screensaverActive) { dismissScreensaver(); - } else { + return; + } + // Auto-home: always reset regardless of screensaver setting + _resetAutoHomeTimer(); + // Screensaver: only if enabled + if (getSettings().screensaver_enabled) { resetInactivityTimer(); } }, { passive: true }); }); _inactivityListenersAttached = true; } + // Always start auto-home timer; screensaver only if enabled + _resetAutoHomeTimer(); if (getSettings().screensaver_enabled) { resetInactivityTimer(); } diff --git a/index.html b/index.html index 5737beb..efa8a91 100644 --- a/index.html +++ b/index.html @@ -333,6 +333,7 @@ +