feat: v1.3.0 — banner notifications, quick-access, swipe navigation, bug fixes
Added: - Expired/expiring product banner alerts with use, throw, edit, dismiss actions - Priority-sorted notifications (expired > expiring > suspicious qty > predictions) - Touch swipe navigation for banner with dot indicators and arrow buttons - Quick-access buttons on inventory (4 recent + 8 popular products) - Auto-refresh banner every 5 min on dashboard - Edit expiry dates directly from expired/expiring notifications Fixed: - Ignore negative BLE scale readings - Banner re-appearing after edit (confirmation now persisted) - False consumption predictions when inventory was manually edited - Kiosk overlay no longer blocks web app header
This commit is contained in:
@@ -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/),
|
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).
|
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
|
## [1.2.0] - 2026-04-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -49,6 +49,9 @@
|
|||||||
- **Safety ratings** — Smart assessment of expired product safety (by category)
|
- **Safety ratings** — Smart assessment of expired product safety (by category)
|
||||||
- **Quick recipe bar** — One-tap recipe suggestion using expiring products
|
- **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
|
- **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
|
### 📱 Progressive Web App
|
||||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
- **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)
|
- **Setup wizard** — 3-step guided configuration (URL, connection test, gateway)
|
||||||
- **Gateway auto-launch** — Launches the Scale Gateway in the background on startup
|
- **Gateway auto-launch** — Launches the Scale Gateway in the background on startup
|
||||||
- **Camera & mic permissions** — Full hardware access for barcode scanning and voice
|
- **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
|
- **Hard refresh** — ↻ button clears WebView cache to pick up web app updates
|
||||||
- **Update notifications** — Checks GitHub releases every 6h, shows banner when updates available
|
- **Update notifications** — Checks GitHub releases every 6h, shows banner when updates available
|
||||||
- **SSL support** — Accepts self-signed certificates
|
- **SSL support** — Accepts self-signed certificates
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ try {
|
|||||||
getConsumptionPredictions($db);
|
getConsumptionPredictions($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'recent_popular_products':
|
||||||
|
recentPopularProducts($db);
|
||||||
|
break;
|
||||||
|
|
||||||
// ===== AI =====
|
// ===== AI =====
|
||||||
case 'gemini_expiry':
|
case 'gemini_expiry':
|
||||||
geminiReadExpiry();
|
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 =====
|
// ===== CONSUMPTION PREDICTIONS =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1337,6 +1378,30 @@ function getConsumptionPredictions(PDO $db): void {
|
|||||||
|
|
||||||
$restockDate = strtotime($restock['created_at']);
|
$restockDate = strtotime($restock['created_at']);
|
||||||
$restockQty = floatval($restock['quantity']);
|
$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);
|
$daysSinceRestock = max(1, (time() - $restockDate) / 86400);
|
||||||
|
|
||||||
// Predicted remaining qty = restock qty - (daily rate * days since restock)
|
// Predicted remaining qty = restock qty - (daily rate * days since restock)
|
||||||
|
|||||||
@@ -4515,11 +4515,64 @@ body {
|
|||||||
color: #a1977a;
|
color: #a1977a;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 12px 8px;
|
padding: 0 12px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.banner-prediction .alert-banner-counter {
|
.banner-prediction .alert-banner-counter {
|
||||||
color: #7c6cb0;
|
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 {
|
.alert-review {
|
||||||
background: #fffbeb;
|
background: #fffbeb;
|
||||||
border-color: #f59e0b;
|
border-color: #f59e0b;
|
||||||
@@ -5403,3 +5456,118 @@ body {
|
|||||||
.setup-skip-link:hover {
|
.setup-skip-link:hover {
|
||||||
color: #666;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+288
-6
@@ -117,6 +117,8 @@ function _scaleOnMessage(msg) {
|
|||||||
_scaleBattery = msg.battery ?? null;
|
_scaleBattery = msg.battery ?? null;
|
||||||
_scaleUpdateStatus(_scaleConnected ? 'connected' : 'searching');
|
_scaleUpdateStatus(_scaleConnected ? 'connected' : 'searching');
|
||||||
} else if (msg.type === 'weight') {
|
} else if (msg.type === 'weight') {
|
||||||
|
// Ignore negative weight values (tare artifacts, sensor noise)
|
||||||
|
if (parseFloat(msg.value) < 0) return;
|
||||||
_scaleLatestWeight = msg;
|
_scaleLatestWeight = msg;
|
||||||
// Update live reading modal overlay if visible (scale-read modal)
|
// Update live reading modal overlay if visible (scale-read modal)
|
||||||
const live = document.getElementById('scale-reading-live');
|
const live = document.getElementById('scale-reading-live');
|
||||||
@@ -1910,6 +1912,12 @@ function showPage(pageId, param = null) {
|
|||||||
case 'chat': initChat(); 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
|
// Stop scanner when leaving scan page
|
||||||
if (pageId !== 'scan' && pageId !== 'ai') {
|
if (pageId !== 'scan' && pageId !== 'ai') {
|
||||||
stopScanner();
|
stopScanner();
|
||||||
@@ -2189,10 +2197,11 @@ function setReviewConfirmed(inventoryId) {
|
|||||||
let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
|
let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
|
||||||
let _bannerIndex = 0;
|
let _bannerIndex = 0;
|
||||||
let _bannerEditPending = false; // true when editing from banner → dismiss after save
|
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
|
* Load suspicious quantities + consumption predictions + expired + expiring soon,
|
||||||
* banner queue and show the first item.
|
* merge into a single banner queue and show the first item.
|
||||||
*/
|
*/
|
||||||
async function loadBannerAlerts() {
|
async function loadBannerAlerts() {
|
||||||
_bannerQueue = [];
|
_bannerQueue = [];
|
||||||
@@ -2208,7 +2217,25 @@ async function loadBannerAlerts() {
|
|||||||
const items = invData.inventory || [];
|
const items = invData.inventory || [];
|
||||||
const confirmed = getReviewConfirmed();
|
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 => {
|
items.forEach(item => {
|
||||||
if (confirmed[item.id]) return;
|
if (confirmed[item.id]) return;
|
||||||
if (isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit)) {
|
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 || [];
|
const predictions = predData.predictions || [];
|
||||||
predictions.forEach(pred => {
|
predictions.forEach(pred => {
|
||||||
if (confirmed['pred_' + pred.inventory_id]) return;
|
if (confirmed['pred_' + pred.inventory_id]) return;
|
||||||
_bannerQueue.push({ type: 'prediction', data: pred });
|
_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)`);
|
console.log(`[Banner] queue ready: ${_bannerQueue.length} items (${items.length} inv, ${predictions.length} pred, ${Object.keys(confirmed).length} confirmed)`);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2239,11 +2269,51 @@ async function loadBannerAlerts() {
|
|||||||
if (_bannerQueue.length > 0) {
|
if (_bannerQueue.length > 0) {
|
||||||
_bannerIndex = 0;
|
_bannerIndex = 0;
|
||||||
renderBannerItem();
|
renderBannerItem();
|
||||||
|
initBannerSwipe();
|
||||||
} else {
|
} else {
|
||||||
banner.style.display = 'none';
|
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() {
|
function renderBannerItem() {
|
||||||
const banner = document.getElementById('alert-banner');
|
const banner = document.getElementById('alert-banner');
|
||||||
if (!banner || _bannerQueue.length === 0) { if (banner) banner.style.display = 'none'; return; }
|
if (!banner || _bannerQueue.length === 0) { if (banner) banner.style.display = 'none'; return; }
|
||||||
@@ -2258,7 +2328,37 @@ function renderBannerItem() {
|
|||||||
const s = getSettings();
|
const s = getSettings();
|
||||||
const hasScale = s.scale_enabled && s.scale_gateway_url && _scaleConnected;
|
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 = `<button class="btn-banner btn-banner-use" onclick="bannerQuickUse()">${t('dashboard.banner_expired_action_use')}</button>`;
|
||||||
|
btns += `<button class="btn-banner btn-banner-throw" onclick="bannerThrowAway()">${t('dashboard.banner_expired_action_throw')}</button>`;
|
||||||
|
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerExpiry()">${t('dashboard.banner_review_action_edit')}</button>`;
|
||||||
|
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerExpired()">${t('dashboard.banner_review_dismiss')}</button>`;
|
||||||
|
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 = `<button class="btn-banner btn-banner-use" onclick="bannerQuickUse()">${t('dashboard.banner_expiring_action_use')}</button>`;
|
||||||
|
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerExpiry()">${t('dashboard.banner_review_action_edit')}</button>`;
|
||||||
|
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerExpiring()">${t('dashboard.banner_review_dismiss')}</button>`;
|
||||||
|
actionsEl.innerHTML = btns;
|
||||||
|
|
||||||
|
} else if (entry.type === 'review') {
|
||||||
const item = entry.data;
|
const item = entry.data;
|
||||||
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
|
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
|
||||||
banner.className = 'alert-banner';
|
banner.className = 'alert-banner';
|
||||||
@@ -2288,7 +2388,16 @@ function renderBannerItem() {
|
|||||||
actionsEl.innerHTML = btns;
|
actionsEl.innerHTML = btns;
|
||||||
}
|
}
|
||||||
|
|
||||||
counterEl.textContent = _bannerQueue.length > 1 ? `${_bannerIndex + 1} / ${_bannerQueue.length}` : '';
|
if (_bannerQueue.length > 1) {
|
||||||
|
let dots = `<span class="banner-nav-arrow" onclick="bannerPrev()">‹</span>`;
|
||||||
|
dots += _bannerQueue.map((_, i) =>
|
||||||
|
`<span class="banner-dot${i === _bannerIndex ? ' active' : ''}" onclick="_bannerIndex=${i};renderBannerItem()"></span>`
|
||||||
|
).join('');
|
||||||
|
dots += `<span class="banner-nav-arrow" onclick="bannerNext()">›</span>`;
|
||||||
|
counterEl.innerHTML = dots;
|
||||||
|
} else {
|
||||||
|
counterEl.innerHTML = '';
|
||||||
|
}
|
||||||
banner.style.display = '';
|
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
|
// Group items by local category and render with category headers
|
||||||
function renderGroupedByCategory(items, compact = false) {
|
function renderGroupedByCategory(items, compact = false) {
|
||||||
const catGroups = {};
|
const catGroups = {};
|
||||||
@@ -2545,6 +2754,7 @@ async function loadInventory() {
|
|||||||
const data = await api('inventory_list', currentLocation ? { location: currentLocation } : {});
|
const data = await api('inventory_list', currentLocation ? { location: currentLocation } : {});
|
||||||
currentInventory = data.inventory || [];
|
currentInventory = data.inventory || [];
|
||||||
renderInventory(currentInventory);
|
renderInventory(currentInventory);
|
||||||
|
loadQuickAccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Inventory load error:', err);
|
console.error('Inventory load error:', err);
|
||||||
}
|
}
|
||||||
@@ -2626,6 +2836,70 @@ function filterInventory() {
|
|||||||
renderInventory(filtered);
|
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
|
||||||
|
? `<img src="${escapeHtml(product.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">`
|
||||||
|
: catIcon;
|
||||||
|
const brandHtml = product.brand ? `<span class="qa-brand">(${escapeHtml(product.brand)})</span>` : '';
|
||||||
|
return `
|
||||||
|
<button class="quick-access-btn" onclick="quickAccessSelect(${product.product_id})">
|
||||||
|
<div class="qa-img">${imgHtml}</div>
|
||||||
|
<div class="qa-name">${escapeHtml(product.name)}</div>
|
||||||
|
${brandHtml}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =====
|
// ===== ITEM DETAIL MODAL =====
|
||||||
function showItemDetail(inventoryId, productId) {
|
function showItemDetail(inventoryId, productId) {
|
||||||
const item = currentInventory.find(i => i.id === inventoryId);
|
const item = currentInventory.find(i => i.id === inventoryId);
|
||||||
@@ -2877,6 +3151,14 @@ async function submitEditInventory(e, id, productId) {
|
|||||||
showToast('Aggiornato!', 'success');
|
showToast('Aggiornato!', 'success');
|
||||||
if (_bannerEditPending) {
|
if (_bannerEditPending) {
|
||||||
_bannerEditPending = false;
|
_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();
|
dismissBannerItem();
|
||||||
}
|
}
|
||||||
refreshCurrentPage();
|
refreshCurrentPage();
|
||||||
|
|||||||
@@ -456,8 +456,7 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
// Inject triple-tap exit on the header bar
|
// Kiosk overlay removed — exit is handled via the Android settings gear button
|
||||||
injectKioskOverlay()
|
|
||||||
// Check for updates periodically
|
// Check for updates periodically
|
||||||
checkForUpdates()
|
checkForUpdates()
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -127,6 +127,17 @@
|
|||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()" data-i18n-placeholder="inventory.search_placeholder">
|
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()" data-i18n-placeholder="inventory.search_placeholder">
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Quick access: recent & popular products -->
|
||||||
|
<div id="quick-access-section" style="display:none">
|
||||||
|
<div class="quick-access-group" id="quick-recent-group" style="display:none">
|
||||||
|
<h4 class="quick-access-label" data-i18n="inventory.recent_title">🕐 Ultimi usati</h4>
|
||||||
|
<div class="quick-access-grid" id="quick-recent-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="quick-access-group" id="quick-popular-group" style="display:none">
|
||||||
|
<h4 class="quick-access-label" data-i18n="inventory.popular_title">⭐ Più usati</h4>
|
||||||
|
<div class="quick-access-grid quick-access-grid-8" id="quick-popular-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="inventory-list" id="inventory-list"></div>
|
<div class="inventory-list" id="inventory-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -92,12 +92,24 @@
|
|||||||
"banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.",
|
"banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.",
|
||||||
"banner_prediction_action_confirm": "Menge bestätigen",
|
"banner_prediction_action_confirm": "Menge bestätigen",
|
||||||
"banner_prediction_action_weigh": "Mit Waage wiegen",
|
"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": {
|
"inventory": {
|
||||||
"title": "Vorrat",
|
"title": "Vorrat",
|
||||||
"filter_all": "Alle",
|
"filter_all": "Alle",
|
||||||
"search_placeholder": "🔍 Produkt suchen...",
|
"search_placeholder": "🔍 Produkt suchen...",
|
||||||
|
"recent_title": "🕐 Zuletzt verwendet",
|
||||||
|
"popular_title": "⭐ Meistverwendet",
|
||||||
"empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!",
|
"empty": "Keine Produkte hier.\nScanne ein Produkt, um es hinzuzufügen!",
|
||||||
"no_items_found": "Keine Bestandseinträge gefunden"
|
"no_items_found": "Keine Bestandseinträge gefunden"
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-1
@@ -92,12 +92,24 @@
|
|||||||
"banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.",
|
"banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.",
|
||||||
"banner_prediction_action_confirm": "Confirm quantity",
|
"banner_prediction_action_confirm": "Confirm quantity",
|
||||||
"banner_prediction_action_weigh": "Weigh with scale",
|
"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": {
|
"inventory": {
|
||||||
"title": "Pantry",
|
"title": "Pantry",
|
||||||
"filter_all": "All",
|
"filter_all": "All",
|
||||||
"search_placeholder": "🔍 Search product...",
|
"search_placeholder": "🔍 Search product...",
|
||||||
|
"recent_title": "🕐 Recently used",
|
||||||
|
"popular_title": "⭐ Most used",
|
||||||
"empty": "No products here.\nScan a product to add it!",
|
"empty": "No products here.\nScan a product to add it!",
|
||||||
"no_items_found": "No inventory items found"
|
"no_items_found": "No inventory items found"
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-1
@@ -92,12 +92,24 @@
|
|||||||
"banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.",
|
"banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.",
|
||||||
"banner_prediction_action_confirm": "Confermo quantità",
|
"banner_prediction_action_confirm": "Confermo quantità",
|
||||||
"banner_prediction_action_weigh": "Pesa con bilancia",
|
"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": {
|
"inventory": {
|
||||||
"title": "Dispensa",
|
"title": "Dispensa",
|
||||||
"filter_all": "Tutti",
|
"filter_all": "Tutti",
|
||||||
"search_placeholder": "🔍 Cerca prodotto...",
|
"search_placeholder": "🔍 Cerca prodotto...",
|
||||||
|
"recent_title": "🕐 Ultimi usati",
|
||||||
|
"popular_title": "⭐ Più usati",
|
||||||
"empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!",
|
"empty": "Nessun prodotto qui.\nScansiona un prodotto per aggiungerlo!",
|
||||||
"no_items_found": "Nessuna voce di inventario trovata"
|
"no_items_found": "Nessuna voce di inventario trovata"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user