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 @@