v1.7.25 — partial throw from banner, barcode fallback, related stock, Bring! re-add fix

- Fix: Bring! items re-appearing after manual removal (missing _markBringPurchased call in removeBringItem / confirmShoppingItemFound; autoAddCriticalItems now respects blocklist for qty=0 items)
- Fix: barcode false 'not found' — new _offFetchProduct() helper tries UPC-A↔EAN-13 candidates × 2 locales with auto-retry; UPCItemDB fallback also iterates candidates
- Fix: bannerThrowAway() now opens partial-throw modal (location + qty input + throw-all button) instead of immediately discarding everything
- Add: related stock card on action page — shows same-family inventory items when scanning a branded product
This commit is contained in:
dadaloop82
2026-05-23 08:17:20 +00:00
parent 5c1afaaaf5
commit 6320b575e0
9 changed files with 295 additions and 183 deletions
+10
View File
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.24-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.25-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
+125 -131
View File
@@ -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);
}
+20
View File
@@ -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;
+132 -48
View File
@@ -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 `<span class="related-stock-item">${escapeHtml(label)}: <strong>${qtyStr}</strong> ${locIcon}</span>`;
}).join('');
relatedEl.innerHTML = `<div class="action-related-stock-card">🔍 ${t('action.related_stock_title')}: ${parts}</div>`;
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]) =>
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
@@ -9231,7 +9284,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${t('move.title')}</h3>
<button class="modal-close" onclick="clearMoveModalTimer();closeModal();showPage('dashboard')"></button>
<button class="modal-close" onclick="clearMoveModalTimer();_saveVacuumAndStay(${openedId || 0})"></button>
</div>
<div style="padding:0 16px 16px">
<p style="margin-bottom:12px">${t('move.question').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest')).replace('{name}', `<strong>${escapeHtml(product.name)}</strong>`)}</p>
@@ -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();
}
+1
View File
@@ -333,6 +333,7 @@
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
<div class="product-preview product-preview-small" id="action-product-preview"></div>
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
<div id="action-related-stock" style="display:none"></div>
<div class="action-buttons" id="action-buttons-container">
<button class="btn btn-huge btn-success" onclick="showAddForm()">
<span class="btn-icon">📥</span>
+2 -1
View File
@@ -227,7 +227,8 @@
"throw_btn": "🗑️ ENTSORGEN",
"throw_sub": "wegwerfen",
"edit_sub": "Ablauf, Ort…",
"create_recipe_btn": "Rezept"
"create_recipe_btn": "Rezept",
"related_stock_title": "Auch zuhause"
},
"add": {
"title": "Zum Vorrat hinzufügen",
+2 -1
View File
@@ -227,7 +227,8 @@
"throw_btn": "🗑️ DISCARD",
"throw_sub": "throw away",
"edit_sub": "expiry, location…",
"create_recipe_btn": "Recipe"
"create_recipe_btn": "Recipe",
"related_stock_title": "Also at home"
},
"add": {
"title": "Add to Pantry",
+2 -1
View File
@@ -227,7 +227,8 @@
"throw_btn": "🗑️ BUTTA",
"throw_sub": "butta il prodotto",
"edit_sub": "scadenza, luogo…",
"create_recipe_btn": "Ricetta"
"create_recipe_btn": "Ricetta",
"related_stock_title": "Hai anche in casa"
},
"add": {
"title": "Aggiungi alla Dispensa",