diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd46d5..5b1b405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to EverShelf will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2026-04-18 + +### Added +- **Expired product banner** — Dashboard notifications for expired products with use, throw away, edit, and dismiss actions +- **Expiring soon banner** — Dashboard notifications for products expiring within 3 days with use, edit, and dismiss actions +- **Priority-sorted notifications** — Banner alerts sorted by urgency: expired > expiring > suspicious quantities > consumption predictions +- **Swipe navigation** — Touch swipe left/right to browse banner notifications, with dot indicators and arrow buttons +- **Quick-access buttons** — Inventory page shows 4 recently used and up to 8 most popular products for quick selection +- **Recent & popular products API** — New `recent_popular_products` endpoint +- **Auto-refresh** — Banner notifications refresh every 5 minutes while on the dashboard +- **Edit from expiry banner** — Correct expiry dates directly from expired/expiring notifications + +### Fixed +- **Negative scale values** — BLE scale readings with negative weight are now ignored +- **Banner re-appearing after edit** — Editing from a banner now persists the confirmation so it doesn't reappear on dashboard reload +- **False consumption predictions** — Manual inventory edits (updated_at > last restock) now use the correct baseline for prediction calculations +- **Kiosk overlay blocking header** — Removed injected exit/refresh buttons from the web app header in kiosk mode + ## [1.2.0] - 2026-04-13 ### Changed diff --git a/README.md b/README.md index a1ea535..ac844f4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,9 @@ - **Safety ratings** — Smart assessment of expired product safety (by category) - **Quick recipe bar** — One-tap recipe suggestion using expiring products - **Anomaly banner** — Scrollable banner with suspicious quantities and consumption prediction mismatches, with one-tap correction or inline edit +- **Expired/expiring alerts** — Priority-sorted banner notifications for expired and soon-to-expire products with use, throw, edit, and dismiss actions +- **Swipe navigation** — Touch swipe or tap arrows/dots to browse banner notifications +- **Quick-access buttons** — Recently used and most popular products shown on the inventory page for fast access ### 📱 Progressive Web App - **Mobile-first design** — Optimized for phones, works on tablets and desktop @@ -73,7 +76,6 @@ - **Setup wizard** — 3-step guided configuration (URL, connection test, gateway) - **Gateway auto-launch** — Launches the Scale Gateway in the background on startup - **Camera & mic permissions** — Full hardware access for barcode scanning and voice -- **Exit button** — Visible ✕ button with confirmation dialog - **Hard refresh** — ↻ button clears WebView cache to pick up web app updates - **Update notifications** — Checks GitHub releases every 6h, shows banner when updates available - **SSL support** — Accepts self-signed certificates diff --git a/api/index.php b/api/index.php index 3f331d4..9f8d846 100644 --- a/api/index.php +++ b/api/index.php @@ -193,6 +193,10 @@ try { getConsumptionPredictions($db); break; + case 'recent_popular_products': + recentPopularProducts($db); + break; + // ===== AI ===== case 'gemini_expiry': geminiReadExpiry(); @@ -1275,6 +1279,43 @@ function getStats(PDO $db): void { ]); } +// ===== RECENT & POPULAR PRODUCTS ===== +function recentPopularProducts(PDO $db): void { + // Last 4 distinct products used (type='out'), most recent first + $recentStmt = $db->query(" + SELECT DISTINCT t.product_id, p.name, p.brand, p.category, p.image_url, p.unit, + MAX(t.created_at) as last_used + FROM transactions t + JOIN products p ON p.id = t.product_id + WHERE t.type = 'out' + GROUP BY t.product_id + ORDER BY last_used DESC + LIMIT 4 + "); + $recent = $recentStmt->fetchAll(PDO::FETCH_ASSOC); + $recentIds = array_map(fn($r) => (int)$r['product_id'], $recent); + + // Top 12 most frequently used products (to allow filtering out recent ones client-side) + $popularStmt = $db->query(" + SELECT t.product_id, p.name, p.brand, p.category, p.image_url, p.unit, + COUNT(*) as usage_count + FROM transactions t + JOIN products p ON p.id = t.product_id + WHERE t.type = 'out' + AND t.created_at >= datetime('now', '-90 days') + GROUP BY t.product_id + ORDER BY usage_count DESC + LIMIT 12 + "); + $popular = $popularStmt->fetchAll(PDO::FETCH_ASSOC); + + echo json_encode([ + 'recent' => $recent, + 'popular' => $popular, + 'recent_ids' => $recentIds, + ]); +} + // ===== CONSUMPTION PREDICTIONS ===== /** @@ -1337,6 +1378,30 @@ function getConsumptionPredictions(PDO $db): void { $restockDate = strtotime($restock['created_at']); $restockQty = floatval($restock['quantity']); + + // If inventory was manually edited (updated_at > last restock), use the + // manual update as baseline instead — otherwise the prediction is comparing + // against a stale restock quantity that no longer reflects reality. + $lastManualUpdate = strtotime($item['updated_at']); + if ($lastManualUpdate > $restockDate) { + // Inventory was manually corrected after last restock → use current qty + // as a fresh baseline from that point; only consider OUT transactions + // that happened AFTER the manual update. + $txnsSinceUpdate = $db->prepare(" + SELECT SUM(quantity) as total + FROM transactions + WHERE product_id = ? AND location = ? AND type = 'out' + AND created_at > ? + "); + $txnsSinceUpdate->execute([$pid, $loc, $item['updated_at']]); + $usedSinceUpdate = floatval($txnsSinceUpdate->fetchColumn() ?: 0); + $daysSinceBaseline = max(1, (time() - $lastManualUpdate) / 86400); + // The effective "restock" qty is what inventory had at manual edit time + // which is current qty + what was consumed since then + $restockQty = floatval($item['quantity']) + $usedSinceUpdate; + $restockDate = $lastManualUpdate; + } + $daysSinceRestock = max(1, (time() - $restockDate) / 86400); // Predicted remaining qty = restock qty - (daily rate * days since restock) diff --git a/assets/css/style.css b/assets/css/style.css index 42aac8e..a54bebc 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -4515,11 +4515,64 @@ body { color: #a1977a; text-align: center; padding: 0 12px 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; } .banner-prediction .alert-banner-counter { color: #7c6cb0; } +/* Dot indicators */ +.banner-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: rgba(0,0,0,0.18); + cursor: pointer; + transition: background 0.2s, transform 0.2s; +} +.banner-dot.active { + background: rgba(0,0,0,0.55); + transform: scale(1.3); +} +.banner-expired .banner-dot.active { background: #dc2626; } +.banner-expiring .banner-dot.active { background: #ea580c; } +.banner-prediction .banner-dot.active { background: #7c3aed; } + +/* Nav arrows */ +.banner-nav-arrow { + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + color: rgba(0,0,0,0.35); + user-select: none; + padding: 0 2px; + line-height: 1; + transition: color 0.15s; +} +.banner-nav-arrow:active { color: rgba(0,0,0,0.7); } + +/* Swipe slide animations */ +@keyframes bannerSlideInLeft { + from { opacity: 0; transform: translateX(60px); } + to { opacity: 1; transform: translateX(0); } +} +@keyframes bannerSlideInRight { + from { opacity: 0; transform: translateX(-60px); } + to { opacity: 1; transform: translateX(0); } +} +.banner-slide-left .alert-banner-inner, +.banner-slide-left .alert-banner-actions { + animation: bannerSlideInLeft 0.25s ease-out; +} +.banner-slide-right .alert-banner-inner, +.banner-slide-right .alert-banner-actions { + animation: bannerSlideInRight 0.25s ease-out; +} + .alert-review { background: #fffbeb; border-color: #f59e0b; @@ -5403,3 +5456,118 @@ body { .setup-skip-link:hover { color: #666; } + +/* ===== QUICK ACCESS BUTTONS ===== */ +#quick-access-section { + margin-bottom: 12px; +} +.quick-access-group { + margin-bottom: 10px; +} +.quick-access-label { + font-size: 0.82rem; + font-weight: 700; + color: var(--text-light); + margin-bottom: 8px; + padding-left: 2px; +} +.quick-access-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} +.quick-access-grid-8 { + grid-template-columns: repeat(4, 1fr); +} +.quick-access-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 4px 8px; + background: var(--bg-card); + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; + box-shadow: var(--shadow); + min-width: 0; +} +.quick-access-btn:active { + transform: scale(0.95); +} +.quick-access-btn:hover { + box-shadow: var(--shadow-lg); + border-color: var(--primary-light); +} +.qa-img { + width: 44px; + height: 44px; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + background: var(--bg); + flex-shrink: 0; +} +.qa-img img { + width: 100%; + height: 100%; + object-fit: cover; +} +.qa-name { + font-size: 0.72rem; + font-weight: 600; + text-align: center; + line-height: 1.2; + max-height: 2.4em; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + word-break: break-word; + color: var(--text); +} +.qa-brand { + font-size: 0.62rem; + color: var(--text-muted); + text-align: center; + line-height: 1.1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* ===== BANNER VARIANTS: EXPIRED & EXPIRING ===== */ +.alert-banner.banner-expired { + background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%); + border-color: #dc2626; +} +.banner-expired .alert-banner-title { + color: #991b1b; +} +.banner-expired .alert-banner-counter { + color: #b91c1c; +} +.alert-banner.banner-expiring { + background: linear-gradient(135deg, #fff7ed 0%, #fed7aa 100%); + border-color: #f97316; +} +.banner-expiring .alert-banner-title { + color: #9a3412; +} +.banner-expiring .alert-banner-counter { + color: #c2410c; +} +.btn-banner-use { + background: #dbeafe; + color: #1d4ed8; +} +.btn-banner-throw { + background: #fee2e2; + color: #dc2626; +} diff --git a/assets/js/app.js b/assets/js/app.js index fb23e15..dbafd7b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -117,6 +117,8 @@ function _scaleOnMessage(msg) { _scaleBattery = msg.battery ?? null; _scaleUpdateStatus(_scaleConnected ? 'connected' : 'searching'); } else if (msg.type === 'weight') { + // Ignore negative weight values (tare artifacts, sensor noise) + if (parseFloat(msg.value) < 0) return; _scaleLatestWeight = msg; // Update live reading modal overlay if visible (scale-read modal) const live = document.getElementById('scale-reading-live'); @@ -1909,6 +1911,12 @@ function showPage(pageId, param = null) { case 'settings': loadSettingsUI(); break; case 'chat': initChat(); break; } + + // Auto-refresh banner notifications while on dashboard (every 5 min) + if (_bannerRefreshTimer) { clearInterval(_bannerRefreshTimer); _bannerRefreshTimer = null; } + if (pageId === 'dashboard') { + _bannerRefreshTimer = setInterval(() => loadBannerAlerts(), 5 * 60 * 1000); + } // Stop scanner when leaving scan page if (pageId !== 'scan' && pageId !== 'ai') { @@ -2189,10 +2197,11 @@ function setReviewConfirmed(inventoryId) { 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 /** - * Load suspicious quantities + consumption predictions, merge into a single - * banner queue and show the first item. + * Load suspicious quantities + consumption predictions + expired + expiring soon, + * merge into a single banner queue and show the first item. */ async function loadBannerAlerts() { _bannerQueue = []; @@ -2208,7 +2217,25 @@ async function loadBannerAlerts() { const items = invData.inventory || []; const confirmed = getReviewConfirmed(); - // 1. Suspicious quantities + // 1. Expired products (highest priority) - derived from inventory + items.forEach(item => { + if (!item.expiry_date) return; + const days = daysUntilExpiry(item.expiry_date); + if (days >= 0) return; // not expired + if (confirmed['exp_' + item.id]) return; + _bannerQueue.push({ type: 'expired', data: { ...item, days_expired: Math.abs(days) } }); + }); + + // 2. Products expiring very soon (today, tomorrow, within 3 days) + items.forEach(item => { + if (!item.expiry_date) return; + const days = daysUntilExpiry(item.expiry_date); + if (days < 0 || days > 3) return; + if (confirmed['exps_' + item.id]) return; + _bannerQueue.push({ type: 'expiring', data: { ...item, days_left: days } }); + }); + + // 3. Suspicious quantities items.forEach(item => { if (confirmed[item.id]) return; if (isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit)) { @@ -2223,13 +2250,16 @@ async function loadBannerAlerts() { } }); - // 2. Consumption predictions that don't match actual quantity + // 4. Consumption predictions that don't match actual quantity const predictions = predData.predictions || []; predictions.forEach(pred => { if (confirmed['pred_' + pred.inventory_id]) return; _bannerQueue.push({ type: 'prediction', data: pred }); }); + // Sort by priority (highest first) + _bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a)); + console.log(`[Banner] queue ready: ${_bannerQueue.length} items (${items.length} inv, ${predictions.length} pred, ${Object.keys(confirmed).length} confirmed)`); } catch (e) { @@ -2239,11 +2269,51 @@ async function loadBannerAlerts() { if (_bannerQueue.length > 0) { _bannerIndex = 0; renderBannerItem(); + initBannerSwipe(); } else { banner.style.display = 'none'; } } +/** + * Compute a numeric priority score for a banner item. + * Higher = more important = shown first. + * + * Priority tiers: + * 1000+ : expired (longer ago = higher) + * 500-999: expiring today/tomorrow/soon (sooner = higher) + * 200-499: suspicious quantities (low stock > high stock > package) + * 100-199: consumption predictions (higher deviation% = higher) + */ +function _bannerPriority(entry) { + switch (entry.type) { + case 'expired': { + const d = entry.data.days_expired || 0; + // Expired longer = more urgent; base 1000 + days (capped) + return 1000 + Math.min(d, 500); + } + case 'expiring': { + const d = entry.data.days_left ?? 3; + // Today=999, tomorrow=998, 2d=997, 3d=996 + return 999 - d; + } + case 'review': { + const w = entry.data.warning || ''; + // Low stock is more urgent than too-much + if (w.includes('Troppo poco')) return 400; + if (w.includes('Troppo')) return 300; + return 200; // package suspicion + } + case 'prediction': { + const dev = entry.data.deviation_pct || 0; + // Higher deviation = more important, capped at 99 + return 100 + Math.min(dev, 99); + } + default: + return 0; + } +} + function renderBannerItem() { const banner = document.getElementById('alert-banner'); if (!banner || _bannerQueue.length === 0) { if (banner) banner.style.display = 'none'; return; } @@ -2258,7 +2328,37 @@ function renderBannerItem() { const s = getSettings(); const hasScale = s.scale_enabled && s.scale_gateway_url && _scaleConnected; - if (entry.type === 'review') { + if (entry.type === 'expired') { + const item = entry.data; + const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); + const daysText = item.days_expired === 0 ? t('dashboard.banner_expired_today') : t('dashboard.banner_expired_days', { days: item.days_expired }); + banner.className = 'alert-banner banner-expired'; + iconEl.textContent = '🚫'; + titleEl.textContent = `${t('dashboard.banner_expired_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; + detailEl.textContent = `${daysText} · ${qtyDisplay}`; + let btns = ``; + btns += ``; + btns += ``; + btns += ``; + actionsEl.innerHTML = btns; + + } else if (entry.type === 'expiring') { + const item = entry.data; + const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); + let urgencyText; + if (item.days_left === 0) urgencyText = t('dashboard.banner_expiring_today'); + else if (item.days_left === 1) urgencyText = t('dashboard.banner_expiring_tomorrow'); + else urgencyText = t('dashboard.banner_expiring_days', { days: item.days_left }); + banner.className = 'alert-banner banner-expiring'; + iconEl.textContent = '⏰'; + titleEl.textContent = `${t('dashboard.banner_expiring_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`; + detailEl.textContent = `${urgencyText} · ${qtyDisplay}`; + let btns = ``; + btns += ``; + btns += ``; + actionsEl.innerHTML = btns; + + } else if (entry.type === 'review') { const item = entry.data; const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); banner.className = 'alert-banner'; @@ -2288,7 +2388,16 @@ function renderBannerItem() { actionsEl.innerHTML = btns; } - counterEl.textContent = _bannerQueue.length > 1 ? `${_bannerIndex + 1} / ${_bannerQueue.length}` : ''; + if (_bannerQueue.length > 1) { + let dots = ``; + dots += _bannerQueue.map((_, i) => + `` + ).join(''); + dots += ``; + counterEl.innerHTML = dots; + } else { + counterEl.innerHTML = ''; + } banner.style.display = ''; } @@ -2353,6 +2462,106 @@ function editReviewItem(inventoryId, productId) { }); } +// --- Banner handlers for expired & expiring --- +function bannerQuickUse() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry) return; + const item = entry.data; + quickUse(item.product_id, item.location); + dismissBannerItem(); +} + +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(); +} + +function editBannerExpiry() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || (entry.type !== 'expired' && entry.type !== 'expiring')) return; + _bannerEditPending = true; + editReviewItem(entry.data.id, entry.data.product_id); +} + +function dismissBannerExpired() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'expired') return; + setReviewConfirmed('exp_' + entry.data.id); + dismissBannerItem(); +} + +function dismissBannerExpiring() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'expiring') return; + setReviewConfirmed('exps_' + entry.data.id); + dismissBannerItem(); +} + +// --- Banner swipe navigation --- +let _bannerTouchStartX = 0; +let _bannerTouchStartY = 0; +let _bannerSwiping = false; + +function initBannerSwipe() { + const banner = document.getElementById('alert-banner'); + if (!banner || banner._swipeInit) return; + banner._swipeInit = true; + + banner.addEventListener('touchstart', e => { + if (_bannerQueue.length <= 1) return; + const touch = e.touches[0]; + _bannerTouchStartX = touch.clientX; + _bannerTouchStartY = touch.clientY; + _bannerSwiping = true; + }, { passive: true }); + + banner.addEventListener('touchend', e => { + if (!_bannerSwiping || _bannerQueue.length <= 1) return; + _bannerSwiping = false; + const touch = e.changedTouches[0]; + const dx = touch.clientX - _bannerTouchStartX; + const dy = touch.clientY - _bannerTouchStartY; + // Only horizontal swipes (at least 40px, and more horizontal than vertical) + if (Math.abs(dx) < 40 || Math.abs(dy) > Math.abs(dx)) return; + if (dx < 0) bannerNext(); + else bannerPrev(); + }, { passive: true }); +} + +function bannerNext() { + if (_bannerQueue.length <= 1) return; + const banner = document.getElementById('alert-banner'); + banner.classList.remove('banner-slide-left', 'banner-slide-right'); + void banner.offsetWidth; // force reflow + _bannerIndex = (_bannerIndex + 1) % _bannerQueue.length; + banner.classList.add('banner-slide-left'); + renderBannerItem(); +} + +function bannerPrev() { + if (_bannerQueue.length <= 1) return; + const banner = document.getElementById('alert-banner'); + banner.classList.remove('banner-slide-left', 'banner-slide-right'); + void banner.offsetWidth; + _bannerIndex = (_bannerIndex - 1 + _bannerQueue.length) % _bannerQueue.length; + banner.classList.add('banner-slide-right'); + renderBannerItem(); +} + // Group items by local category and render with category headers function renderGroupedByCategory(items, compact = false) { const catGroups = {}; @@ -2545,6 +2754,7 @@ async function loadInventory() { const data = await api('inventory_list', currentLocation ? { location: currentLocation } : {}); currentInventory = data.inventory || []; renderInventory(currentInventory); + loadQuickAccess(); } catch (err) { console.error('Inventory load error:', err); } @@ -2626,6 +2836,70 @@ function filterInventory() { renderInventory(filtered); } +// ===== QUICK ACCESS: RECENT & POPULAR ===== +async function loadQuickAccess() { + const section = document.getElementById('quick-access-section'); + if (!section) return; + try { + const data = await api('recent_popular_products'); + const recent = data.recent || []; + const popular = data.popular || []; + const recentIds = data.recent_ids || []; + + const recentGroup = document.getElementById('quick-recent-group'); + const popularGroup = document.getElementById('quick-popular-group'); + const recentGrid = document.getElementById('quick-recent-grid'); + const popularGrid = document.getElementById('quick-popular-grid'); + + // Render recent (max 4) + if (recent.length > 0) { + recentGrid.innerHTML = recent.slice(0, 4).map(p => renderQuickAccessBtn(p)).join(''); + recentGroup.style.display = ''; + } else { + recentGroup.style.display = 'none'; + } + + // Render popular (max 8), excluding products already in recent + const filteredPopular = popular.filter(p => !recentIds.includes(parseInt(p.product_id))); + if (filteredPopular.length > 0) { + popularGrid.innerHTML = filteredPopular.slice(0, 8).map(p => renderQuickAccessBtn(p)).join(''); + popularGroup.style.display = ''; + } else { + popularGroup.style.display = 'none'; + } + + section.style.display = (recent.length > 0 || filteredPopular.length > 0) ? '' : 'none'; + } catch (e) { + console.warn('[QuickAccess] load failed:', e); + section.style.display = 'none'; + } +} + +function renderQuickAccessBtn(product) { + const catIcon = CATEGORY_ICONS[mapToLocalCategory(product.category, product.name)] || '📦'; + const imgHtml = product.image_url + ? `` + : catIcon; + const brandHtml = product.brand ? `(${escapeHtml(product.brand)})` : ''; + return ` + `; +} + +function quickAccessSelect(productId) { + // Find the product in current inventory and show its detail + const item = currentInventory.find(i => i.product_id === productId); + if (item) { + showItemDetail(item.id, item.product_id); + } else { + // Product not in current view (maybe different location), navigate to it + quickUse(productId, currentLocation || 'dispensa'); + } +} + // ===== ITEM DETAIL MODAL ===== function showItemDetail(inventoryId, productId) { const item = currentInventory.find(i => i.id === inventoryId); @@ -2877,6 +3151,14 @@ async function submitEditInventory(e, id, productId) { showToast('Aggiornato!', 'success'); if (_bannerEditPending) { _bannerEditPending = false; + // Mark the item as confirmed so it does NOT reappear in the banner + const entry = _bannerQueue[_bannerIndex]; + if (entry) { + if (entry.type === 'review') setReviewConfirmed(entry.data.id); + else if (entry.type === 'prediction') setReviewConfirmed('pred_' + entry.data.inventory_id); + else if (entry.type === 'expired') setReviewConfirmed('exp_' + entry.data.id); + else if (entry.type === 'expiring') setReviewConfirmed('exps_' + entry.data.id); + } dismissBannerItem(); } refreshCurrentPage(); diff --git a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt index 2950208..d153605 100644 --- a/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt +++ b/evershelf-kiosk/app/src/main/kotlin/it/dadaloop/evershelf/kiosk/KioskActivity.kt @@ -456,8 +456,7 @@ class KioskActivity : AppCompatActivity() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) - // Inject triple-tap exit on the header bar - injectKioskOverlay() + // Kiosk overlay removed — exit is handled via the Android settings gear button // Check for updates periodically checkForUpdates() } diff --git a/index.html b/index.html index 718fb29..d720d24 100644 --- a/index.html +++ b/index.html @@ -127,6 +127,17 @@ + +
diff --git a/translations/de.json b/translations/de.json index 6e4c247..2a37325 100644 --- a/translations/de.json +++ b/translations/de.json @@ -92,12 +92,24 @@ "banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.", "banner_prediction_action_confirm": "Menge bestätigen", "banner_prediction_action_weigh": "Mit Waage wiegen", - "banner_prediction_action_edit": "Korrigieren" + "banner_prediction_action_edit": "Korrigieren", + "banner_expired_title": "Abgelaufenes Produkt", + "banner_expired_today": "Heute abgelaufen", + "banner_expired_days": "Seit {days} Tagen abgelaufen", + "banner_expired_action_use": "Trotzdem verwenden", + "banner_expired_action_throw": "Wegwerfen", + "banner_expiring_title": "Bald ablaufend", + "banner_expiring_today": "Läuft heute ab!", + "banner_expiring_tomorrow": "Läuft morgen ab", + "banner_expiring_days": "Läuft in {days} Tagen ab", + "banner_expiring_action_use": "Jetzt verwenden" }, "inventory": { "title": "Vorrat", "filter_all": "Alle", "search_placeholder": "🔍 Produkt suchen...", + "recent_title": "🕐 Zuletzt verwendet", + "popular_title": "⭐ Meistverwendet", "empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!", "no_items_found": "Keine Bestandseinträge gefunden" }, diff --git a/translations/en.json b/translations/en.json index 8162475..df7a6e5 100644 --- a/translations/en.json +++ b/translations/en.json @@ -92,12 +92,24 @@ "banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.", "banner_prediction_action_confirm": "Confirm quantity", "banner_prediction_action_weigh": "Weigh with scale", - "banner_prediction_action_edit": "Correct" + "banner_prediction_action_edit": "Correct", + "banner_expired_title": "Expired product", + "banner_expired_today": "Expired today", + "banner_expired_days": "Expired {days} days ago", + "banner_expired_action_use": "Use anyway", + "banner_expired_action_throw": "Throw away", + "banner_expiring_title": "Expiring soon", + "banner_expiring_today": "Expires today!", + "banner_expiring_tomorrow": "Expires tomorrow", + "banner_expiring_days": "Expires in {days} days", + "banner_expiring_action_use": "Use now" }, "inventory": { "title": "Pantry", "filter_all": "All", "search_placeholder": "🔍 Search product...", + "recent_title": "🕐 Recently used", + "popular_title": "⭐ Most used", "empty": "No products here.\nScan a product to add it!", "no_items_found": "No inventory items found" }, diff --git a/translations/it.json b/translations/it.json index 42d90b9..2731f48 100644 --- a/translations/it.json +++ b/translations/it.json @@ -92,12 +92,24 @@ "banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.", "banner_prediction_action_confirm": "Confermo quantità", "banner_prediction_action_weigh": "Pesa con bilancia", - "banner_prediction_action_edit": "Correggi" + "banner_prediction_action_edit": "Correggi", + "banner_expired_title": "Prodotto scaduto", + "banner_expired_today": "Scaduto oggi", + "banner_expired_days": "Scaduto da {days} giorni", + "banner_expired_action_use": "Usa comunque", + "banner_expired_action_throw": "Butta via", + "banner_expiring_title": "In scadenza", + "banner_expiring_today": "Scade oggi!", + "banner_expiring_tomorrow": "Scade domani", + "banner_expiring_days": "Scade tra {days} giorni", + "banner_expiring_action_use": "Usa ora" }, "inventory": { "title": "Dispensa", "filter_all": "Tutti", "search_placeholder": "🔍 Cerca prodotto...", + "recent_title": "🕐 Ultimi usati", + "popular_title": "⭐ Più usati", "empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!", "no_items_found": "Nessuna voce di inventario trovata" },