feat: banner alerts, consumption predictions, scale improvements, kiosk app

- Banner notification system: suspicious quantities + consumption prediction alerts
- Consumption predictions API: tracks 90-day usage patterns, flags >30% deviations
- Scale stability timeout: 5s → 10s, auto-confirm remains 5s
- Scale integration in edit form: weigh button with inline live display
- Banner edit/weigh actions open edit form directly with scale activation
- Cooking mode: Italian aliases + stem-prefix matching for ingredients
- Recipe regeneration: tracks rejected ingredients for diversity
- Settings migration: localStorage → .env server-side storage
- Expiry priority: mandatory ≤3 days, recommended ≤7 days in recipes
- Scale bug fixes: clear stale weight, double-submit guard, cap deduction
- Android kiosk app (evershelf-kiosk): WebView + embedded BLE scale gateway
- Version bump to 1.4.0
This commit is contained in:
dadaloop82
2026-04-16 14:46:30 +00:00
parent 3ff91b3018
commit 3e25fcd5df
25 changed files with 3431 additions and 1500 deletions
+109 -10
View File
@@ -879,24 +879,19 @@ body {
}
.scale-live-box.scale-low-weight {
border-color: #dc2626;
background: #fef2f2;
animation: scaleLowWeightBlink 0.8s ease-in-out infinite alternate;
}
@media (prefers-color-scheme: dark) {
.scale-live-box.scale-low-weight {
background: #3b0000;
}
}
.scale-low-weight .scale-live-val {
color: #dc2626 !important;
animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate;
}
.scale-low-weight .scale-live-label {
color: #dc2626 !important;
font-weight: 600;
animation: scaleLowTextBlink 0.65s ease-in-out infinite alternate;
}
@keyframes scaleLowWeightBlink {
from { border-color: #dc2626; box-shadow: none; }
to { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.25); }
@keyframes scaleLowTextBlink {
from { opacity: 1; }
to { opacity: 0.2; }
}
.btn-accent {
@@ -4421,6 +4416,110 @@ body {
}
/* ===== REVIEW SECTION ===== */
/* ===== ALERT TOP BANNER ===== */
.alert-banner {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 1.5px solid #f59e0b;
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
animation: bannerSlideIn 0.35s ease-out;
}
@keyframes bannerSlideIn {
from { opacity: 0; transform: translateY(-12px); }
to { opacity: 1; transform: translateY(0); }
}
.alert-banner.banner-prediction {
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
border-color: #8b5cf6;
}
.alert-banner-inner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 12px 8px;
}
.alert-banner-icon {
font-size: 1.5rem;
flex-shrink: 0;
line-height: 1;
}
.alert-banner-body {
flex: 1;
min-width: 0;
}
.alert-banner-title {
font-weight: 700;
font-size: 0.95rem;
color: #92400e;
line-height: 1.3;
}
.banner-prediction .alert-banner-title {
color: #5b21b6;
}
.alert-banner-detail {
font-size: 0.82rem;
color: #78716c;
margin-top: 2px;
line-height: 1.4;
}
.alert-banner-close {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.08);
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #78716c;
}
.alert-banner-actions {
display: flex;
gap: 8px;
padding: 0 12px 10px;
flex-wrap: wrap;
}
.alert-banner-actions .btn-banner {
flex: 1;
min-width: 80px;
padding: 8px 12px;
border-radius: 8px;
border: none;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
text-align: center;
}
.btn-banner-ok {
background: #d1fae5;
color: #059669;
}
.btn-banner-edit {
background: #e0e7ff;
color: #4338ca;
}
.btn-banner-weigh {
background: #f3e8ff;
color: #7c3aed;
}
.btn-banner-confirm {
background: #d1fae5;
color: #059669;
}
.alert-banner-counter {
font-size: 0.72rem;
color: #a1977a;
text-align: center;
padding: 0 12px 8px;
}
.banner-prediction .alert-banner-counter {
color: #7c6cb0;
}
.alert-review {
background: #fffbeb;
border-color: #f59e0b;
+333 -110
View File
@@ -70,7 +70,7 @@ let _scaleWeightCallback = null; // pending on-demand weight request callback
let _scaleLatestWeight = null; // last received weight message
let _scaleAutoConfirmTimer = null; // countdown timer for auto-confirm after stable weight
let _scaleAutoConfirmRAF = null; // rAF handle for auto-confirm progress bar animation
let _scaleStabilityTimer = null; // setTimeout: wait 5 s stable before starting confirm bar
let _scaleStabilityTimer = null; // setTimeout: wait 10 s stable before starting confirm bar
let _scaleStabilityRAF = null; // rAF handle for stability progress bar in the live box
let _scaleStabilityVal = null; // value we are currently timing for stability
let _scaleUserDismissed = false; // user tapped or edited → don't retrigger for same value
@@ -120,6 +120,9 @@ function _scaleOnMessage(msg) {
// Update live reading modal overlay if visible (scale-read modal)
const live = document.getElementById('scale-reading-live');
if (live) live.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
// Also update edit-form inline scale reading if visible
const editLive = document.getElementById('edit-scale-reading');
if (editLive) editLive.textContent = `${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
// Always update the persistent live box on the use page (every message, stable or not)
_scaleUpdateLiveBox(msg);
// If weight is NOT stable: stop any running timer/bar but keep the sentinel value.
@@ -204,7 +207,7 @@ function _scaleUpdateLiveBox(msg) {
// Weight too low — show red flashing warning
box.classList.add('scale-low-weight');
if (valEl) valEl.textContent = `${raw} ${msg.unit || 'kg'}`;
if (lblEl) lblEl.textContent = '< 10 g · inserisci manualmente';
if (lblEl) lblEl.textContent = t('scale.low_weight');
} else {
box.classList.remove('scale-low-weight');
const stIcon = msg.stable ? ' ✓' : ' …';
@@ -436,12 +439,12 @@ function _cancelScaleStabilityWait() {
}
/**
* Start a 5-second stability wait with an animated progress bar in the live box.
* Calls onStable() when weight unchanged for 5 s.
* Start a 10-second stability wait with an animated progress bar in the live box.
* Calls onStable() when weight unchanged for 10 s.
*/
function _startScaleStabilityWait(onStable) {
_cancelScaleStabilityWait();
const duration = 5000;
const duration = 10000;
const start = performance.now();
const bar = document.getElementById('scale-live-progress-bar');
@@ -535,6 +538,50 @@ function readScaleWeight(targetInputId, getUnit) {
// Weight data streams continuously via SSE; _scaleWeightCallback fires on the next stable reading
}
/**
* Inline scale reading for the edit-inventory modal.
* Shows a live weight display inside the form and fills edit-qty on stable reading.
*/
function readScaleForEdit() {
if (!_scaleConnected) { showToast('⚖️ ' + t('scale.not_connected'), 'error'); return; }
const section = document.getElementById('edit-scale-section');
const btn = document.getElementById('btn-scale-edit');
if (section) section.style.display = '';
if (btn) btn.style.display = 'none';
_scaleWeightCallback = (msg) => {
const editQty = document.getElementById('edit-qty');
const editUnit = document.getElementById('edit-unit');
if (!editQty || !editUnit) return;
let unit = editUnit.value;
const isConf = unit === 'conf';
let confSize = 0;
if (isConf) confSize = parseFloat(document.getElementById('edit-conf-size')?.value) || 0;
let raw = parseFloat(msg.value);
const srcUnit = (msg.unit || 'kg').toLowerCase();
let grams;
if (srcUnit === 'kg') grams = raw * 1000;
else if (srcUnit === 'lbs' || srcUnit === 'lb') grams = raw * 453.592;
else if (srcUnit === 'oz') grams = raw * 28.3495;
else grams = raw; // g or ml
let val;
if (isConf && confSize > 0) {
val = Math.round((grams / confSize) * 100) / 100;
} else {
val = Math.round(grams);
}
editQty.value = val;
editQty.dispatchEvent(new Event('input'));
if (section) section.style.display = 'none';
if (btn) btn.style.display = '';
showToast(`⚖️ ${val} ${unit}`, 'success');
};
}
function _scaleShowReadingModal(targetInputId, unit) {
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
@@ -1388,16 +1435,29 @@ function debounce(fn, ms) {
async function syncSettingsFromDB() {
try {
// Primary: load from server .env
const serverSettings = await api('get_settings');
const s = getSettings();
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url','spesa_provider',
'spesa_ai_prompt','meal_plan_enabled','tts_enabled','tts_url','tts_token',
'tts_method','tts_auth_type','tts_content_type','tts_payload_key'];
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
}
}
_settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
// Also load review_confirmed from DB
const res = await api('app_settings_get');
if (res.success && res.settings) {
// Spesa credentials still come from DB (not .env)
if (res.settings.user_prefs) {
const db = res.settings.user_prefs;
const s = getSettings();
// Merge DB settings into local (DB wins for shared prefs)
for (const key of ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'spesa_provider','spesa_ai_prompt','spesa_email','spesa_password',
'spesa_logged_in','spesa_user','spesa_data','spesa_token']) {
for (const key of ['spesa_email','spesa_password','spesa_logged_in',
'spesa_user','spesa_data','spesa_token']) {
if (db[key] !== undefined) s[key] = db[key];
}
_settingsCache = s;
@@ -1489,31 +1549,56 @@ async function loadSettingsUI() {
const ttsExtraEl = document.getElementById('setting-tts-extra-fields');
if (ttsExtraEl) ttsExtraEl.value = s.tts_extra_fields || '';
// Load server-side settings if not already set locally
// Load server-side settings as primary source
try {
const serverSettings = await api('get_settings');
if (!s.gemini_key && serverSettings.gemini_key) {
document.getElementById('setting-gemini-key').value = serverSettings.gemini_key;
// Merge all server settings into local cache (server wins)
const serverKeys = ['gemini_key','bring_email','bring_password',
'default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url','spesa_provider',
'spesa_ai_prompt','meal_plan_enabled',
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
'tts_content_type','tts_payload_key'];
let changed = false;
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
changed = true;
}
}
if (!s.bring_email && serverSettings.bring_email) {
document.getElementById('setting-bring-email').value = serverSettings.bring_email;
if (changed) {
_settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
// Re-populate UI with merged values
document.getElementById('setting-gemini-key').value = s.gemini_key || '';
document.getElementById('setting-bring-email').value = s.bring_email || '';
document.getElementById('setting-bring-password').value = s.bring_password || '';
document.getElementById('setting-default-persons').value = s.default_persons || 1;
document.getElementById('setting-pref-veloce').checked = !!s.pref_veloce;
document.getElementById('setting-pref-pocafame').checked = !!s.pref_pocafame;
document.getElementById('setting-pref-scadenze').checked = !!s.pref_scadenze;
document.getElementById('setting-pref-healthy').checked = !!s.pref_healthy;
document.getElementById('setting-pref-opened').checked = !!s.pref_opened;
document.getElementById('setting-pref-zerowaste').checked = !!s.pref_zerowaste;
document.getElementById('setting-dietary').value = s.dietary || '';
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
renderAppliances(s.appliances || []);
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true;
if (ttsUrlEl) ttsUrlEl.value = s.tts_url || '';
if (ttsTokenEl) ttsTokenEl.value = s.tts_token || '';
if (ttsMethEl) ttsMethEl.value = s.tts_method || 'POST';
if (ttsAuthTypeEl) ttsAuthTypeEl.value = s.tts_auth_type || 'bearer';
if (ttsCtEl) ttsCtEl.value = s.tts_content_type || 'application/json';
if (ttsPayloadKeyEl) ttsPayloadKeyEl.value = s.tts_payload_key || 'message';
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
if (scaleUrlUiEl) scaleUrlUiEl.value = s.scale_gateway_url || '';
const mpEnabledUp = s.meal_plan_enabled !== false;
if (mpEnabledEl) mpEnabledEl.checked = mpEnabledUp;
if (mpConfigSection) mpConfigSection.style.display = mpEnabledUp ? '' : 'none';
if (mpLegendCard) mpLegendCard.style.display = mpEnabledUp ? '' : 'none';
}
// Load TTS defaults from server .env if not set locally
if (!s.tts_url && serverSettings.tts_url) {
s.tts_url = serverSettings.tts_url;
s.tts_token = serverSettings.tts_token || '';
s.tts_method = serverSettings.tts_method || 'POST';
s.tts_auth_type = serverSettings.tts_auth_type || 'bearer';
s.tts_content_type = serverSettings.tts_content_type || 'application/json';
s.tts_payload_key = serverSettings.tts_payload_key || 'message';
s.tts_enabled = serverSettings.tts_enabled || false;
saveSettingsToStorage(s);
// Update UI fields with server values
if (ttsUrlEl) ttsUrlEl.value = s.tts_url;
if (ttsTokenEl) ttsTokenEl.value = s.tts_token;
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled;
}
} catch(e) { /* ignore */ }
} catch(e) { /* offline, use local */ }
// Scale settings
const scaleEnabledUiEl = document.getElementById('setting-scale-enabled');
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
@@ -1647,12 +1732,34 @@ async function saveSettings() {
if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim();
saveSettingsToStorage(s);
// Also save to server .env
// Save ALL settings to server .env
try {
const result = await api('save_settings', {}, 'POST', {
gemini_key: s.gemini_key,
bring_email: s.bring_email,
bring_password: s.bring_password
bring_password: s.bring_password,
default_persons: s.default_persons,
pref_veloce: s.pref_veloce,
pref_pocafame: s.pref_pocafame,
pref_scadenze: s.pref_scadenze,
pref_healthy: s.pref_healthy,
pref_opened: s.pref_opened,
pref_zerowaste: s.pref_zerowaste,
dietary: s.dietary,
appliances: s.appliances,
camera_facing: s.camera_facing,
scale_enabled: s.scale_enabled,
scale_gateway_url: s.scale_gateway_url,
spesa_provider: s.spesa_provider,
spesa_ai_prompt: s.spesa_ai_prompt,
meal_plan_enabled: s.meal_plan_enabled,
tts_enabled: s.tts_enabled,
tts_url: s.tts_url,
tts_token: s.tts_token,
tts_method: s.tts_method,
tts_auth_type: s.tts_auth_type,
tts_content_type: s.tts_content_type,
tts_payload_key: s.tts_payload_key,
});
const statusEl = document.getElementById('settings-status');
if (result.success) {
@@ -1871,8 +1978,8 @@ async function loadDashboard() {
expiredSection.style.display = 'none';
}
// Review suspicious quantities
loadReviewItems();
// Banner alerts (suspicious quantities + consumption predictions)
loadBannerAlerts();
// Waste vs consumption chart
const wasteSection = document.getElementById('waste-chart-section');
@@ -2007,7 +2114,7 @@ function quickRecipeSuggestion() {
}, 500);
}
// === SUSPICIOUS QUANTITY REVIEW ===
// === SUSPICIOUS QUANTITY THRESHOLDS ===
const QTY_THRESHOLDS = {
'pz': { min: 0.3, max: 50 },
'conf': { min: 0.3, max: 50 },
@@ -2018,17 +2125,16 @@ const QTY_THRESHOLDS = {
function isSuspiciousQty(qty, unit) {
const n = parseFloat(qty);
if (isNaN(n) || n <= 0) return false;
const t = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz'];
return n < t.min || n > t.max;
const th = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz'];
return n < th.min || n > th.max;
}
function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) {
const n = parseFloat(defaultQty);
if (!n || n <= 0) return false;
// For conf products, default_quantity is in package_unit (g, ml, etc.)
const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit;
const t = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
return n > t.max;
const th = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
return n > th.max;
}
function getReviewConfirmed() {
@@ -2040,87 +2146,170 @@ function setReviewConfirmed(inventoryId) {
const c = getReviewConfirmed();
c[inventoryId] = Date.now();
_reviewConfirmedCache = c;
// Persist to shared DB
api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {});
}
async function loadReviewItems() {
const section = document.getElementById('alert-review');
const list = document.getElementById('review-list');
// === ALERT BANNER SYSTEM (replaces old review table) ===
let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
let _bannerIndex = 0;
/**
* Load suspicious quantities + consumption predictions, merge into a single
* banner queue and show the first item.
*/
async function loadBannerAlerts() {
_bannerQueue = [];
_bannerIndex = 0;
const banner = document.getElementById('alert-banner');
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
try {
const data = await api('inventory_list');
const items = data.inventory || [];
const [invData, predData] = await Promise.all([
api('inventory_list'),
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
]);
const items = invData.inventory || [];
const confirmed = getReviewConfirmed();
const suspicious = items.filter(item => {
if (confirmed[item.id]) return false;
return isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
// 1. 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)) {
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
const suspQty = isSuspiciousQty(item.quantity, item.unit);
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
let warning;
if (suspDq && !suspQty) warning = '📦 Conf. sospetta';
else if (parseFloat(item.quantity) < t_.min) warning = '⬇️ Troppo poco';
else warning = '⬆️ Troppo';
_bannerQueue.push({ type: 'review', data: { ...item, warning } });
}
});
if (suspicious.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
list.innerHTML = suspicious.map(item => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const t = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
const suspQty = isSuspiciousQty(item.quantity, item.unit);
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
let warning;
if (suspDq && !suspQty) warning = '📦 Conf. sospetta';
else if (parseFloat(item.quantity) < t.min) warning = '⬇️ Troppo poco';
else warning = '⬆️ Troppo';
return `
<div class="review-item" id="review-item-${item.id}">
<div class="review-item-info">
<span class="review-item-icon">${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="">` : catIcon}</span>
<div class="review-item-text">
<div class="review-item-name">${escapeHtml(item.name)}</div>
<div class="review-item-meta">${locInfo.icon} ${locInfo.label} · <span class="review-warn">${warning}</span></div>
</div>
</div>
<div class="review-item-qty">
<span class="review-qty-value">${qtyDisplay}</span>
</div>
<div class="review-item-actions">
<button class="btn-review btn-review-ok" onclick="confirmReviewItem(${item.id})" title="È corretto"></button>
<button class="btn-review btn-review-edit" onclick="editReviewItem(${item.id}, ${item.product_id})" title="Modifica"></button>
</div>
</div>`;
}).join('');
} catch(e) {
section.style.display = 'none';
// 2. 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 });
});
console.log(`[Banner] queue ready: ${_bannerQueue.length} items (${items.length} inv, ${predictions.length} pred, ${Object.keys(confirmed).length} confirmed)`);
} catch (e) {
console.error('[Banner] loadBannerAlerts error:', e);
}
if (_bannerQueue.length > 0) {
_bannerIndex = 0;
renderBannerItem();
} else {
banner.style.display = 'none';
}
}
function confirmReviewItem(inventoryId) {
setReviewConfirmed(inventoryId);
const el = document.getElementById(`review-item-${inventoryId}`);
if (el) {
el.style.transition = 'opacity 0.3s, transform 0.3s';
el.style.opacity = '0';
el.style.transform = 'translateX(60px)';
setTimeout(() => {
el.remove();
// Hide section if empty
const list = document.getElementById('review-list');
if (!list.children.length) {
document.getElementById('alert-review').style.display = 'none';
}
}, 300);
function renderBannerItem() {
const banner = document.getElementById('alert-banner');
if (!banner || _bannerQueue.length === 0) { if (banner) banner.style.display = 'none'; return; }
if (_bannerIndex >= _bannerQueue.length) _bannerIndex = 0;
const entry = _bannerQueue[_bannerIndex];
const iconEl = document.getElementById('alert-banner-icon');
const titleEl = document.getElementById('alert-banner-title');
const detailEl = document.getElementById('alert-banner-detail');
const actionsEl = document.getElementById('alert-banner-actions');
const counterEl = document.getElementById('alert-banner-counter');
const s = getSettings();
const hasScale = s.scale_enabled && s.scale_gateway_url && _scaleConnected;
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';
iconEl.textContent = '⚠️';
titleEl.textContent = `${t('dashboard.banner_review_title')}: ${item.name}`;
detailEl.textContent = `${item.warning} · ${qtyDisplay}`;
let btns = `<button class="btn-banner btn-banner-ok" onclick="confirmBannerReview()">${t('dashboard.banner_review_action_ok')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerReview()">${t('dashboard.banner_review_action_edit')}</button>`;
if (hasScale) {
btns += `<button class="btn-banner btn-banner-weigh" onclick="weighBannerItem()">⚖️ ${t('dashboard.banner_review_action_weigh')}</button>`;
}
actionsEl.innerHTML = btns;
} else if (entry.type === 'prediction') {
const pred = entry.data;
banner.className = 'alert-banner banner-prediction';
iconEl.textContent = '📊';
titleEl.textContent = `${t('dashboard.banner_prediction_title')}: ${pred.name}`;
const expTxt = t('prediction.expected_qty').replace('{expected}', pred.expected_qty).replace('{unit}', pred.unit);
const actTxt = t('prediction.actual_qty').replace('{actual}', pred.actual_qty).replace('{unit}', pred.unit);
detailEl.innerHTML = `${expTxt} · ${actTxt}<br><small>${t('prediction.check_suggestion')}</small>`;
let btns = `<button class="btn-banner btn-banner-confirm" onclick="confirmBannerPrediction()">${t('dashboard.banner_prediction_action_confirm')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerPrediction()">${t('dashboard.banner_prediction_action_edit')}</button>`;
if (hasScale) {
btns += `<button class="btn-banner btn-banner-weigh" onclick="weighBannerItem()">⚖️ ${t('dashboard.banner_prediction_action_weigh')}</button>`;
}
actionsEl.innerHTML = btns;
}
counterEl.textContent = _bannerQueue.length > 1 ? `${_bannerIndex + 1} / ${_bannerQueue.length}` : '';
banner.style.display = '';
}
function dismissBannerItem() {
_bannerQueue.splice(_bannerIndex, 1);
if (_bannerQueue.length === 0) {
document.getElementById('alert-banner').style.display = 'none';
return;
}
if (_bannerIndex >= _bannerQueue.length) _bannerIndex = 0;
renderBannerItem();
}
function confirmBannerReview() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'review') return;
setReviewConfirmed(entry.data.id);
showToast(t('toast.quantity_confirmed'), 'success');
dismissBannerItem();
}
function editBannerReview() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'review') return;
editReviewItem(entry.data.id, entry.data.product_id);
}
function confirmBannerPrediction() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'prediction') return;
setReviewConfirmed('pred_' + entry.data.inventory_id);
showToast(t('toast.quantity_confirmed'), 'success');
dismissBannerItem();
}
function editBannerPrediction() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'prediction') return;
editReviewItem(entry.data.inventory_id, entry.data.product_id);
}
function weighBannerItem() {
const entry = _bannerQueue[_bannerIndex];
if (!entry) return;
const item = entry.data;
const targetId = entry.type === 'prediction' ? item.inventory_id : item.id;
// Navigate to edit form and auto-start scale reading
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
editInventoryItem(targetId);
setTimeout(() => readScaleForEdit(), 200);
});
}
function editReviewItem(inventoryId, productId) {
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
editInventoryItem(inventoryId);
});
}
@@ -2468,6 +2657,7 @@ function closeModal() {
_cancelScaleAutoConfirm(false);
_scaleRecipeAutoFillPaused = false;
_scaleUserDismissed = false;
_scaleWeightCallback = null;
}
async function quickUse(productId, location) {
@@ -2540,6 +2730,12 @@ function editInventoryItem(id) {
const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : '';
const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g';
// Determine if scale is available for this item's unit
const s = getSettings();
const effectiveUnit = isConf ? (item.package_unit || 'g') : (item.unit || 'pz');
const scaleEditReady = s.scale_enabled && s.scale_gateway_url && _scaleConnected &&
(effectiveUnit === 'g' || effectiveUnit === 'ml');
window._editingProduct = { name: item.name, category: item.category || '', _isOpened: !!item.opened_at };
// Rebuild modal content for editing (don't close and reopen - just replace content)
@@ -2556,6 +2752,14 @@ function editInventoryItem(id) {
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
</div>
${scaleEditReady ? `
<div id="edit-scale-section" style="display:none;text-align:center;padding:10px;background:linear-gradient(135deg,#f3e8ff,#ede9fe);border-radius:10px;margin-top:8px">
<div style="font-size:1.8rem;font-weight:bold;color:#5b21b6" id="edit-scale-reading"> </div>
<div style="font-size:0.78rem;color:#7c6cb0;margin-top:2px">${t('scale.place_on_scale')}</div>
</div>
<button type="button" id="btn-scale-edit" class="btn btn-secondary scale-read-btn" style="margin-top:8px;width:100%"
onclick="readScaleForEdit()"> ${t('scale.read_btn')}</button>
` : ''}
</div>
<div class="form-group">
<label>📏 Unità di misura</label>
@@ -4723,11 +4927,14 @@ async function submitAdd(e) {
}
// ===== USE FROM INVENTORY =====
let _useSubmitting = false; // double-submit guard
function showUseForm() {
renderUsePreview();
_useConfMode = null; // reset
_useSubmitting = false;
_scaleUserDismissed = false;
_scaleStabilityVal = null;
_scaleLatestWeight = null; // clear stale weight from previous product
_cancelScaleAutoConfirm(false);
document.getElementById('use-quantity').value = 1;
document.getElementById('use-location').value = 'dispensa';
@@ -5294,6 +5501,9 @@ async function submitUseAll() {
async function submitUse(e) {
e.preventDefault();
if (_useSubmitting) return; // prevent double-submit from scale auto-confirm
_useSubmitting = true;
_cancelScaleAutoConfirm(false); // stop any running auto-confirm
showLoading(true);
try {
let qty = parseFloat(document.getElementById('use-quantity').value) || 1;
@@ -5314,6 +5524,7 @@ async function submitUse(e) {
location: document.getElementById('use-location').value,
});
showLoading(false);
_useSubmitting = false;
if (result.success) {
const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty;
showToast(`📤 Usato ${usedText} di ${currentProduct.name}`, 'success');
@@ -5332,6 +5543,7 @@ async function submitUse(e) {
}
} catch (err) {
showLoading(false);
_useSubmitting = false;
showToast(t('error.connection'), 'error');
}
}
@@ -7660,6 +7872,7 @@ function viewArchivedRecipe(idx) {
let _cachedRecipe = null;
let _generatedTodayTitles = []; // client-side list, robust vs race conditions
let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... }
let _rejectedRecipeIngredients = []; // ingredient names from previously rejected recipes
function openRecipeDialog() {
const meal = getMealType();
@@ -8701,14 +8914,18 @@ function _renderMealPlanHint(mealSlot) {
}
function regenerateRecipe() {
// Collect main ingredients from the rejected recipe to exclude them
if (_cachedRecipe && _cachedRecipe.recipe && _cachedRecipe.recipe.ingredients) {
const mainIngs = _cachedRecipe.recipe.ingredients
.filter(i => i.from_pantry)
.map(i => i.name);
_rejectedRecipeIngredients = [...new Set([..._rejectedRecipeIngredients, ...mainIngs])];
}
_cachedRecipe = null;
// Use the meal the user currently has selected (not the auto-detected one)
const meal = getSelectedMealType();
// increment variation counter for this meal slot
_recipeVariationCount[meal] = (_recipeVariationCount[meal] || 0) + 1;
document.getElementById('recipe-result').style.display = 'none';
document.getElementById('recipe-loading').style.display = 'none';
// Keep all existing form settings (persons, chips, meal) — just show the form again
document.getElementById('recipe-ask').style.display = '';
}
@@ -8717,6 +8934,11 @@ async function generateRecipe() {
const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
const settings = getSettings();
// Reset rejected ingredients on first generation (not regeneration)
if ((_recipeVariationCount[meal] || 0) === 0) {
_rejectedRecipeIngredients = [];
}
// Determine meal plan type for today's selected slot,
// but only if the user has NOT unchecked the meal-plan chip
const mealPlanChipWrap = document.getElementById('recipe-opt-mealplan-wrap');
@@ -8755,6 +8977,7 @@ async function generateRecipe() {
today_recipes: [...new Set([...await getTodayRecipeTitles(), ..._generatedTodayTitles])],
meal_plan_type: mealPlanType,
variation: _recipeVariationCount[meal] || 0,
rejected_ingredients: _rejectedRecipeIngredients,
});
if (!result.success) {