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
+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();
}