fix: shopping list accuracy, Bring! cleanup server-side, vacuum prompt, recipe appliances

## v1.7.5

### Added
- Vacuum sealed prompt after item use (conf/weighted units, auto-dismiss 8s)
- Multi-function appliance awareness in Gemini recipe prompts (Cookeo/Bimby/Thermomix)
- Server-side Bring! cleanup in cron (no client page load required)
- shopping_name field in inventory_list API response

### Fixed
- Bring! cleanup: false token match (Succo/Frutta from product name tokens)
- Bring! cleanup: expired item with fresh family stock no longer flagged critical (Verdure)
- Bring! remove: catalog items now removed via German key fallback (Formaggio→Käse)
- Shopping list: isExpiringSoon false positives (requires pctLeft < 50%)
- Shopping list: expired batch suppressed when fresh restock >= 50%
- Cross-device cleanup: detect app-added items via spec markers not localStorage
- API fetch: cache: 'no-store' on all api() calls
- Shopping page: 45s polling for multi-client sync
This commit is contained in:
dadaloop82
2026-05-10 10:54:35 +00:00
parent 10114dae50
commit f65fb4365c
6 changed files with 714 additions and 159 deletions
+261 -113
View File
@@ -2500,7 +2500,7 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
url += `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
});
}
const opts = { method };
const opts = { method, cache: 'no-store' };
if (body) {
opts.headers = { 'Content-Type': 'application/json', ...extraHeaders };
opts.body = JSON.stringify(body);
@@ -2599,6 +2599,20 @@ function showPage(pageId, param = null) {
if (pageId === 'dashboard') {
_bannerRefreshTimer = setInterval(() => loadBannerAlerts(), 5 * 60 * 1000);
}
// Auto-refresh shopping list every 45s while on shopping page so all clients stay in sync
if (_shoppingPollTimer) { clearInterval(_shoppingPollTimer); _shoppingPollTimer = null; }
if (pageId === 'shopping') {
_shoppingPollTimer = setInterval(() => {
loadShoppingList._bgCall = true;
loadShoppingList();
loadSmartShopping().then(() => {
_syncOnBringFlags();
renderSmartShopping();
updateShoppingTabCounts();
});
}, 45 * 1000);
}
// Stop scanner when leaving scan page
if (pageId !== 'scan' && pageId !== 'ai') {
@@ -3479,6 +3493,7 @@ let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
let _bannerIndex = 0;
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)
/**
* Load suspicious quantities + consumption predictions + expired + expiring soon,
@@ -3726,7 +3741,9 @@ function renderBannerItem() {
: t('expiry.expired_suffix');
titleEl.textContent = `${item.name}${item.brand ? ' (' + item.brand + ')' : ''} ${expiredSuffix}`;
const baseDetail = t('dashboard.banner_expired_detail').replace('{when}', daysText).replace('{qty}', qtyDisplay);
detailEl.innerHTML = `${baseDetail} <span class="banner-safety-tip banner-safety-${safety.level}">${safety.icon} ${safety.tip}</span>`;
const locationTag = item.location ? ` · <strong>${escapeHtml(item.location)}</strong>` : '';
const expiryTag = item.expiry_date ? ` · scade il <strong>${escapeHtml(item.expiry_date)}</strong>` : '';
detailEl.innerHTML = `${baseDetail}${locationTag}${expiryTag} <span class="banner-safety-tip banner-safety-${safety.level}">${safety.icon} ${safety.tip}</span>`;
let btns = '';
if (safety.level !== 'danger') {
btns += `<button class="btn-banner btn-banner-use" onclick="bannerQuickUse()">${t('dashboard.banner_expired_action_use')}</button>`;
@@ -4852,6 +4869,19 @@ async function initScanner() {
}
}
// ===== EAN-13 / EAN-8 CHECKSUM VALIDATOR =====
function validateEANChecksum(code) {
const s = String(code).replace(/\D/g, '');
if (s.length !== 13 && s.length !== 8) return false;
const digits = s.split('').map(Number);
const last = digits.pop();
const sum = digits.reduce((acc, d, i) => {
return acc + d * (s.length === 13 ? (i % 2 === 0 ? 1 : 3) : (i % 2 === 0 ? 3 : 1));
}, 0);
const check = (10 - (sum % 10)) % 10;
return check === last;
}
// ===== NATIVE BarcodeDetector SCANNER =====
async function startNativeScanner(videoEl) {
if (quaggaRunning) return;
@@ -4868,6 +4898,8 @@ async function startNativeScanner(videoEl) {
let lastDetected = '';
let detectCount = 0;
let detectionHistory = {};
let quaggaParallelStarted = false;
const startTime = Date.now();
scanLog('Native BarcodeDetector started');
@@ -4882,6 +4914,15 @@ async function startNativeScanner(videoEl) {
frameCount++;
if (frameCount === 1) updateFeedback('scanning');
// After 2s without detection, also start Quagga in parallel as backup
if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) {
quaggaParallelStarted = true;
scanLog('Native: 2s elapsed, spawning Quagga in parallel');
quaggaRunning = false; // temporarily release so Quagga can start
startQuaggaScanner(videoEl);
quaggaRunning = true; // re-take ownership (Quagga will share)
}
try {
const barcodes = await detector.detect(videoEl);
@@ -4903,11 +4944,14 @@ async function startNativeScanner(videoEl) {
detectCount = 1;
}
if (detectCount >= 2 || detectionHistory[code].count >= 2) {
// EAN/UPC have built-in checksum — confirm on first hit for speed.
// For other formats (code_128, code_39) require 2 to avoid false reads.
const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format);
if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) {
scanning = false;
quaggaRunning = false;
updateFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames`);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames (${format})`);
onBarcodeDetected(code);
return;
}
@@ -4949,7 +4993,7 @@ function startQuaggaScanner(videoEl) {
let detectionHistory = {};
// Alternate between full frame and center-cropped for better detection
let scanPass = 0; // 0=full, 1=center-crop, 2=full-enhanced, 3=center-enhanced
let scanPass = 0; // 0=full, 1=center-crop
function updateScannerFeedback(state) {
if (!scannerLine) return;
@@ -4962,12 +5006,13 @@ function startQuaggaScanner(videoEl) {
const vh = videoEl.videoHeight;
if (pass % 2 === 0) {
// Full frame
canvas.width = vw;
canvas.height = vh;
ctx.drawImage(videoEl, 0, 0);
// Full frame (scaled down for speed)
const scale = 0.75;
canvas.width = Math.round(vw * scale);
canvas.height = Math.round(vh * scale);
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
} else {
// Center crop: 60% of frame, focused on barcode area
// Center crop: 70% wide, 40% tall — focused on barcode area
const cropW = Math.round(vw * 0.7);
const cropH = Math.round(vh * 0.4);
const sx = Math.round((vw - cropW) / 2);
@@ -4977,18 +5022,18 @@ function startQuaggaScanner(videoEl) {
ctx.drawImage(videoEl, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
}
// Apply enhancement on passes 2,3 or always for front cam
if (frontCam || pass >= 2) {
// Apply enhancement for front cam or low-light
if (frontCam) {
enhanceCanvasForBarcode(ctx, canvas.width, canvas.height);
}
return canvas.toDataURL('image/jpeg', 0.95);
return canvas.toDataURL('image/jpeg', 0.85);
}
function scanFrame() {
if (!scanning || !scannerStream) return;
frameCount++;
scanPass = (scanPass + 1) % 4;
scanPass = (scanPass + 1) % 2;
const dataUrl = getFrameDataUrl(scanPass);
@@ -5001,29 +5046,29 @@ function startQuaggaScanner(videoEl) {
const safetyTimer = setTimeout(() => {
if (!callbackCalled && scanning) {
scanLog(`Quagga timeout on f${frameCount}, retrying...`);
setTimeout(scanFrame, 100);
setTimeout(scanFrame, 50);
}
}, 5000);
}, 2000);
try {
const imgSize = Math.max(canvas.width, canvas.height);
Quagga.decodeSingle({
src: dataUrl,
numOfWorkers: 0,
inputStream: { size: Math.min(imgSize, 800) },
inputStream: { size: Math.min(imgSize, 640) },
decoder: {
readers: [
'ean_reader',
'ean_8_reader',
'code_128_reader',
'code_39_reader',
'upc_reader',
'upc_e_reader'
'upc_e_reader',
'code_128_reader',
'code_39_reader'
],
multiple: false
},
locate: true,
locator: { patchSize: 'large', halfSample: false }
locator: { patchSize: 'medium', halfSample: true }
}, function(result) {
callbackCalled = true;
clearTimeout(safetyTimer);
@@ -5047,11 +5092,14 @@ function startQuaggaScanner(videoEl) {
}
const dominated = detectionHistory[code];
if (detectCount >= 2 || dominated.count >= 2) {
const passName2 = ['full','crop'][scanPass];
// EAN/UPC: confirm on first hit (checksum validated)
const highConf = ['ean_reader','ean_8_reader','upc_reader','upc_e_reader'].includes(format);
if (highConf || detectCount >= 2 || dominated.count >= 2) {
scanning = false;
quaggaRunning = false;
updateScannerFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames (consec:${detectCount}, total:${dominated.count})`);
scanLog(`CONFIRMED: ${code} [${passName2}] f${frameCount} consec:${detectCount} total:${dominated.count}`);
onBarcodeDetected(code);
return;
}
@@ -5060,9 +5108,9 @@ function startQuaggaScanner(videoEl) {
}
if (scanning) {
if (frameCount % 20 === 0) {
scanLog(`Scanning... f${frameCount}, partials: ${partialCount}, pass: ${scanPass}`);
scanLog(`Scanning... f${frameCount}, partials: ${partialCount}`);
}
setTimeout(scanFrame, 150);
setTimeout(scanFrame, 60);
}
});
} catch (e) {
@@ -5073,7 +5121,7 @@ function startQuaggaScanner(videoEl) {
}
}
setTimeout(scanFrame, 500);
setTimeout(scanFrame, 200);
}
// Enhance low-quality camera frames for better barcode recognition
@@ -5244,19 +5292,31 @@ async function onBarcodeDetected(barcode) {
function submitManualBarcode() {
const input = document.getElementById('manual-barcode-input');
const barcode = (input.value || '').trim();
if (!barcode) {
showToast(t('error.barcode_empty'), 'error');
input.focus();
autoSubmitEAN(input, true);
}
// Auto-submit when user finishes typing a valid EAN-13 or EAN-8
function autoSubmitEAN(inputEl, force = false) {
const raw = (inputEl.value || '').replace(/\D/g, '');
inputEl.value = raw; // strip non-digits live
if (!raw) return;
const isComplete = raw.length === 13 || raw.length === 8;
const isValid = isComplete && validateEANChecksum(raw);
if (isValid) {
// Auto-submit on valid EAN
stopScanner();
onBarcodeDetected(raw);
return;
}
if (!/^\d{4,14}$/.test(barcode)) {
showToast(t('error.barcode_format'), 'error');
input.focus();
return;
if (force) {
if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; }
if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; }
if (isComplete && !isValid) {
showToast('⚠️ Checksum EAN errato — verifica le cifre', 'warning');
}
stopScanner();
onBarcodeDetected(raw);
}
stopScanner();
onBarcodeDetected(barcode);
}
// ===== QUICK NAME ENTRY (for loose/unpackaged products) =====
@@ -5628,10 +5688,19 @@ async function scanBarcodeForForm() {
</div>
<p style="text-align:center;margin-top:12px;color:var(--text-muted);font-size:0.88rem">${t('scanner.barcode_hint')}</p>
<div style="margin-top:10px;text-align:center">
<input type="text" id="pf-bc-manual" class="form-input" placeholder="${t('scanner.barcode_manual_placeholder')}" inputmode="numeric" style="max-width:260px;display:inline-block">
<input type="text" id="pf-bc-manual" class="form-input" placeholder="${t('scanner.barcode_manual_placeholder')}" inputmode="numeric" maxlength="14" style="max-width:260px;display:inline-block" oninput="
const raw=(this.value||'').replace(/\\D/g,''); this.value=raw;
if((raw.length===13||raw.length===8)&&validateEANChecksum(raw)){
stopStream();
document.getElementById('pf-barcode').value=raw;
_updateBarcodeHint();
document.getElementById('modal-overlay').style.display='none';
if(navigator.vibrate)navigator.vibrate(80);
}
">
<button class="btn btn-primary" style="margin-top:8px;width:100%" onclick="
const v = document.getElementById('pf-bc-manual').value.trim();
if(v){ document.getElementById('pf-barcode').value=v; _updateBarcodeHint(); document.getElementById('modal-overlay').style.display='none'; }
const v = (document.getElementById('pf-bc-manual').value||'').replace(/\\D/g,'');
if(v){ stopStream(); document.getElementById('pf-barcode').value=v; _updateBarcodeHint(); document.getElementById('modal-overlay').style.display='none'; }
">${t('scanner.barcode_use_btn')}</button>
</div>
`;
@@ -5660,8 +5729,11 @@ async function scanBarcodeForForm() {
const barcodes = await detector.detect(video);
if (barcodes.length > 0) {
const code = barcodes[0].rawValue;
const fmt = barcodes[0].format;
detectionHistory[code] = (detectionHistory[code] || 0) + 1;
if (detectionHistory[code] >= 2) {
// EAN/UPC: confirm immediately (checksum-validated by detector)
const highConf = ['ean_13','ean_8','upc_a','upc_e'].includes(fmt);
if (highConf || detectionHistory[code] >= 2) {
scanning = false;
stopStream();
overlayEl.style.display = 'none';
@@ -7498,6 +7570,67 @@ function _matchBringToSmart(bringName, smartItems) {
return null;
}
/**
* Show a small auto-dismissing bottom bar asking the user if the opened product
* was put under vacuum seal. Auto-confirms after DURATION ms with the default value
* (if it was already vacuum sealed default yes, otherwise default no).
* @param {number} openedId - inventory row ID of the opened item
* @param {number|boolean} wasVacuumSealed - previous vacuum_sealed state (0/1)
*/
function _showVacuumPrompt(openedId, wasVacuumSealed) {
const DURATION = 8000;
const defaultYes = !!wasVacuumSealed;
const old = document.getElementById('_vacuum-prompt');
if (old) old.remove();
const bar = document.createElement('div');
bar.id = '_vacuum-prompt';
bar.style.cssText = [
'position:fixed', 'bottom:80px', 'left:50%', 'transform:translateX(-50%)',
'z-index:9999', 'background:#1e293b', 'color:#fff', 'border-radius:14px',
'padding:12px 16px', 'display:flex', 'align-items:center', 'gap:10px',
'box-shadow:0 4px 24px rgba(0,0,0,0.5)', 'max-width:360px',
'width:calc(100% - 32px)', 'box-sizing:border-box', 'overflow:hidden'
].join(';');
bar.innerHTML = `
<span style="flex:1;font-size:0.9rem;line-height:1.3">🔒 Messo <b>sotto vuoto</b>?</span>
<button id="_vac-yes" style="background:#22c55e;color:#fff;border:none;border-radius:8px;padding:7px 14px;font-weight:700;cursor:pointer;white-space:nowrap"></button>
<button id="_vac-no" style="background:#475569;color:#fff;border:none;border-radius:8px;padding:7px 14px;font-weight:700;cursor:pointer;white-space:nowrap">No</button>
<div id="_vac-bar" style="position:absolute;bottom:0;left:0;height:3px;background:#60a5fa;border-radius:0;width:100%"></div>
`;
document.body.appendChild(bar);
let dismissed = false;
let rafH = null;
let timerH = null;
function dismiss(vacuum) {
if (dismissed) return;
dismissed = true;
if (timerH) clearTimeout(timerH);
if (rafH) cancelAnimationFrame(rafH);
bar.remove();
api('inventory_update', {}, 'POST', { id: openedId, vacuum_sealed: vacuum ? 1 : 0 })
.then(() => { if (vacuum) showToast('🔒 Sotto vuoto registrato', 'success'); })
.catch(() => {});
}
bar.querySelector('#_vac-yes').addEventListener('click', () => dismiss(true));
bar.querySelector('#_vac-no').addEventListener('click', () => dismiss(false));
const barEl = bar.querySelector('#_vac-bar');
const start = performance.now();
function tick() {
if (dismissed) return;
const pct = Math.min(100, (performance.now() - start) / DURATION * 100);
if (barEl) barEl.style.width = (100 - pct) + '%';
if (pct < 100) rafH = requestAnimationFrame(tick);
}
rafH = requestAnimationFrame(tick);
timerH = setTimeout(() => dismiss(defaultYes), DURATION);
}
function showLowStockBringPrompt(result, afterCallback) {
const name = result.product_name || currentProduct?.name || '';
// Generic shopping name (e.g. "Affettato" for "Mortadella IGP"). Falls back to
@@ -7924,8 +8057,17 @@ async function submitUse(e) {
const moveCallback = result.remaining > 0
? () => showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id)
: () => showPage('dashboard');
// Check low stock → Bring! prompt
showLowStockBringPrompt(result, moveCallback);
// Check low stock → Bring! prompt, then vacuum seal prompt if product was opened
const afterLowStock = moveCallback;
showLowStockBringPrompt(result, afterLowStock);
// Show vacuum sealed prompt when some stock remains and it's a container type
// (conf/weighted units) or the item was previously vacuum sealed.
// Skip for pz "counting" items (e.g. 3 mele → 2 mele: no vacuum concept).
const _vacUnit = result.product_unit || currentProduct?.unit || '';
const _vacContainer = ['conf','g','kg','ml','l'].includes(_vacUnit) || !!(result.opened_vacuum_sealed);
if (result.opened_id && result.remaining > 0 && _vacContainer) {
setTimeout(() => _showVacuumPrompt(result.opened_id, result.opened_vacuum_sealed ?? 0), 600);
}
} else if (result.duplicate) {
// Silently ignore: this was a scale double-trigger, not a real error
} else {
@@ -8995,90 +9137,100 @@ async function fetchAllPrices(forceRefresh = false) {
* Items not matching any DB product are left untouched (likely manually added by user).
*/
async function cleanupObsoleteBringItems() {
// Run at most once every 30 minutes
// Rate-limit: run at most once every 3 minutes
const lastCleanup = parseInt(localStorage.getItem('_bringCleanupTs') || '0');
if (Date.now() - lastCleanup < 30 * 60 * 1000) return;
if (Date.now() - lastCleanup < 3 * 60 * 1000) return;
localStorage.setItem('_bringCleanupTs', String(Date.now()));
if (!shoppingItems.length || !smartShoppingItems.length) return;
// Load live inventory (has actual quantities unlike products_list)
// 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;
};
// 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.
const smartShoppingNames = new Set(
smartShoppingItems.flatMap(si => [
si.name?.toLowerCase(),
si.shopping_name?.toLowerCase()
].filter(Boolean))
);
const smartShoppingFirstToks = new Map();
for (const si of smartShoppingItems) {
for (const tok of _nameTokens(si.name || '')) {
if (!smartShoppingFirstToks.has(tok)) smartShoppingFirstToks.set(tok, si);
}
if (si.shopping_name) {
for (const tok of _nameTokens(si.shopping_name)) {
if (!smartShoppingFirstToks.has(tok)) smartShoppingFirstToks.set(tok, si);
}
}
}
// Load live inventory from server for stock check
let invItems = [];
try {
const res = await api('inventory_list');
invItems = res.inventory || [];
} catch (e) { return; }
// Build: every significant token of in-stock products → total qty
// Any-token matching groups product families:
// 'Passata di pomodoro' + 'Polpa di pomodoro' share 'pomodoro' → same need
const stockByAnyToken = new Map();
// stock by any token (name) + by shopping_name
const stockByTok = new Map();
const stockBySName = new Map();
for (const inv of invItems) {
const qty = parseFloat(inv.quantity || 0);
if (qty <= 0) continue;
const expiry = inv.expiry_date;
const expired = expiry && new Date(expiry) < new Date();
if (qty <= 0 || expired) continue;
for (const tok of _nameTokens(inv.name || '')) {
stockByAnyToken.set(tok, (stockByAnyToken.get(tok) || 0) + qty);
stockByTok.set(tok, (stockByTok.get(tok) || 0) + qty);
}
const sn = (inv.shopping_name || '').toLowerCase().trim();
if (sn) stockBySName.set(sn, (stockBySName.get(sn) || 0) + qty);
}
// Build: first token of smart item name → smart item
const smartByFirstToken = new Map();
for (const si of smartShoppingItems) {
const first = _nameTokens(si.name)[0];
if (first && !smartByFirstToken.has(first)) smartByFirstToken.set(first, si);
// Also index shopping_name first token
if (si.shopping_name) {
const sFirst = _nameTokens(si.shopping_name)[0];
if (sFirst && !smartByFirstToken.has(sFirst)) smartByFirstToken.set(sFirst, si);
}
}
// User-pinned: items manually added via any path — never auto-remove
let userPinned;
try {
const raw = localStorage.getItem('_userPinnedBring');
const map = raw ? JSON.parse(raw) : {};
const now = Date.now();
let changed = false;
for (const k of Object.keys(map)) {
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
}
if (changed) localStorage.setItem('_userPinnedBring', JSON.stringify(map));
userPinned = map;
} catch(e) { userPinned = {}; }
// Auto-added set: only items the app itself auto-added are candidates for cleanup
const autoAdded = _getAutoAddedBring();
const toRemove = [];
for (const item of shoppingItems) {
const nameLower = item.name.toLowerCase();
const itemFirst = _nameTokens(item.name)[0];
const itemToks = _nameTokens(item.name);
const itemFirst = itemToks[0];
// Safety: only clean up items the app auto-added — NEVER remove manually-added ones
const isAutoAdded = !!(autoAdded[nameLower] ||
(itemFirst && Object.keys(autoAdded).some(k => _nameTokens(k)[0] === itemFirst)));
if (!isAutoAdded) continue;
// Only remove items the app put there
if (!isAppAdded(item)) continue;
// User explicitly pinned this item → skip
if (userPinned[nameLower]) continue;
// Find matching smart item
const smartSi = itemFirst ? smartShoppingFirstToks.get(itemFirst) : undefined;
// Find smart item by first-token match (strict — avoids "latte" matching "latte di soia")
const smartSi = itemFirst ? smartByFirstToken.get(itemFirst) : undefined;
// Smart still considers this critical or high urgency → keep it on the list
// Smart still flags this as critical or high → keep it
if (smartSi && (smartSi.urgency === 'critical' || smartSi.urgency === 'high')) continue;
// Out of stock → the user still needs to buy it, keep it
// Smart says medium AND low stock → keep it
if (smartSi && smartSi.urgency === 'medium' && (smartSi.pct_left ?? 100) < 60) continue;
// Smart has it with 0 qty → keep it (user genuinely needs it)
if (smartSi && (smartSi.current_qty ?? 0) <= 0) continue;
// Smart predicts medium urgency AND stock < 60% → keep it
if (smartSi && smartSi.urgency === 'medium' && (smartSi.pct_left ?? 100) < 60) continue;
// If the item IS still in smart_shopping (but not urgent) AND has no local stock at all,
// give benefit of the doubt and keep it.
// If the item is NOT in smart_shopping at all → trust the server: it's covered → remove.
if (smartSi) {
// Still in smart_shopping (low urgency): verify some stock exists before removing
const hasStock = itemToks.some(tok => (stockByTok.get(tok) || 0) > 0)
|| (stockBySName.get(nameLower) || 0) > 0;
if (!hasStock) continue;
}
// else: not in smart_shopping at all → server decided it's covered → safe to remove
// Check actual inventory stock for this exact item (first-token match)
const stockQty = itemFirst ? (stockByAnyToken.get(itemFirst) || 0) : 0;
if (stockQty <= 0) continue; // no related stock → don't remove
// All guards passed: item is auto-added, stock is sufficient, not urgently needed
// All guards passed: app-added and not urgently needed → remove from Bring!
toRemove.push(item);
}
@@ -9559,19 +9711,15 @@ async function loadShoppingCount() {
el.classList.remove('stat-loading');
}
}
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
_updateSmartUrgencyBadge();
} else {
try {
const smart = await api('smart_shopping');
if (smart.success && smart.items) {
smartShoppingItems = smart.items;
_smartShoppingLastFetch = Date.now();
_updateSmartUrgencyBadge();
}
} catch { /* ignore */ }
}
// Smart urgency badge: always fetch fresh data from server (no browser-side gate)
try {
const smart = await api('smart_shopping');
if (smart.success && smart.items) {
smartShoppingItems = smart.items;
_smartShoppingLastFetch = Date.now();
_updateSmartUrgencyBadge();
}
} catch { /* ignore */ }
_updateDashboardPriceTotal();
}