Files
EverShelf/assets/js/app.js
T
dadaloop82 521d8f8e47 fix: screensaver init timing + gemini key not wiped on settings save
- app.js: move initInactivityWatcher() inside syncSettingsFromDB().then()
  so it reads screensaver_enabled after server sync, not stale localStorage
- app.js: skip gemini_key/bring_password in save_settings POST when empty
  to avoid overwriting server .env with blank values
- api/index.php: add screensaver_enabled to getServerSettings() + saveSettings()
2026-05-06 05:14:10 +00:00

12352 lines
551 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* EverShelf - Main Application JS
* Complete pantry management with barcode scanning, AI identification,
* Bring! shopping list integration, recipe generation, and TTS cooking mode.
*
* @author Stimpfl Daniel <evershelfproject@gmail.com>
* @license MIT
*/
// ===== REMOTE LOGGING + ERROR REPORTING =====
// Two-tier system:
// 1. remoteLog() — batched INFO/WARN/ERROR → existing client_log endpoint (debug tail)
// 2. reportError() — immediate single POST → report_error endpoint → GitHub Issue
const _remoteLogBuffer = [];
let _remoteLogTimer = null;
const _origConsoleError = console.error.bind(console);
const _origConsoleWarn = console.warn.bind(console);
function remoteLog(level, ...args) {
const msg = args.map(a => {
if (a instanceof Error) return `${a.name}: ${a.message}`;
if (typeof a === 'object') try { return JSON.stringify(a); } catch { return String(a); }
return String(a);
}).join(' ');
_remoteLogBuffer.push(`[${level}] ${msg}`);
if (!_remoteLogTimer) {
_remoteLogTimer = setTimeout(flushRemoteLog, 2000);
}
}
function flushRemoteLog() {
_remoteLogTimer = null;
if (_remoteLogBuffer.length === 0) return;
const msgs = _remoteLogBuffer.splice(0);
fetch(`api/index.php?action=client_log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: msgs })
}).catch(() => {});
}
// Override console.error and console.warn to also send remotely
console.error = function(...args) {
_origConsoleError(...args);
remoteLog('ERROR', ...args);
};
console.warn = function(...args) {
_origConsoleWarn(...args);
remoteLog('WARN', ...args);
};
// ── Error reporter: creates/updates GitHub Issues ────────────────────────────
// Rate-limit client-side: max 1 report per fingerprint per page session.
const _reportedFingerprints = new Set();
function reportError(payload) {
// Build fingerprint to deduplicate within the same page session
const fp = `${payload.source}:${payload.type}:${String(payload.message).slice(0, 120)}`;
if (_reportedFingerprints.has(fp)) return;
_reportedFingerprints.add(fp);
const body = Object.assign({
source: 'pwa',
version: document.querySelector('.header-version')?.textContent?.trim() || '',
url: location.href,
user_agent: navigator.userAgent,
}, payload);
fetch('api/index.php?action=report_error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(() => {}); // fire-and-forget; never throw from error handler
// Note: the server will also skip issue creation if this version is not the latest.
}
// ── Webapp update notification ───────────────────────────────────────────────
// Checks both the deployed webapp version and the latest GitHub release.
// Fires on tab focus and every 5 minutes.
const _loadedVersion = (document.querySelector('.header-version')?.textContent?.trim() || '').replace(/^v/, '');
// ── Gemini AI availability ────────────────────────────────────────────────────
// Set to true in _initApp / syncSettingsFromDB once server confirms key is set.
// All AI entry points call _requireGemini() before opening camera / API calls.
let _geminiAvailable = false;
let _demoMode = false;
function _requireGemini() {
if (_geminiAvailable) return true;
showToast(
'🤖 ' + t('error.no_api_key'),
'warning',
6000
);
return false;
}
// Update Gemini button visual state to signal no key configured
function _updateGeminiButtonState() {
const btn = document.querySelector('.header-gemini-btn');
if (!btn) return;
if (_geminiAvailable) {
btn.classList.remove('header-btn-no-ai');
btn.removeAttribute('title');
btn.setAttribute('title', 'Chat con Gemini');
} else {
btn.classList.add('header-btn-no-ai');
btn.setAttribute('title', '🤖 Gemini non configurato — imposta GEMINI_API_KEY nelle impostazioni');
}
}
function _applyDemoModeUI() {
if (!_demoMode) return;
// In demo mode Gemini is always "available" — no real key needed
_geminiAvailable = true;
_updateGeminiButtonState();
// Hide the settings ⚙️ nav button
document.querySelectorAll('.nav-btn[data-page="settings"]').forEach(btn => {
btn.style.display = 'none';
});
// Prevent the setup wizard from showing
const wizard = document.getElementById('setup-wizard');
if (wizard) wizard.style.display = 'none';
// Show a small demo badge in the header
const headerLeft = document.getElementById('header-left');
if (headerLeft && !document.getElementById('_demo_badge')) {
const badge = document.createElement('span');
badge.id = '_demo_badge';
badge.textContent = 'DEMO';
badge.style.cssText = 'font-size:0.6rem;font-weight:800;letter-spacing:0.08em;background:rgba(251,191,36,0.35);color:#fef3c7;border:1px solid rgba(251,191,36,0.5);border-radius:4px;padding:2px 5px;white-space:nowrap;';
headerLeft.appendChild(badge);
}
}
function _checkWebappUpdate() {
const STORAGE_KEY = '_evershelf_update_checked_at';
const SEEN_KEY = '_evershelf_update_seen_ts';
const TTL_MS = 5 * 60 * 1000;
const now = Date.now();
const lastCheck = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
if (now - lastCheck < TTL_MS) return;
localStorage.setItem(STORAGE_KEY, String(now));
fetch('api/index.php?action=check_update', { method: 'GET' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
// Already showing — don't stack
if (document.getElementById('_header_update_pill')) return;
// ── Check 1: server has a newer version deployed since this page loaded ──
const serverVer = (data.webapp_version || '').replace(/^v/, '');
const deployChanged = serverVer && _loadedVersion && serverVer !== _loadedVersion;
// ── Check 2: a newer GitHub release not yet acknowledged ──
const publishedAt = data.published_at || '';
const seenTs = localStorage.getItem(SEEN_KEY) || '';
const latestTag = (data.latest_tag || '').replace(/^v/, '');
const releaseNewer = publishedAt && publishedAt !== seenTs &&
/^\d+\.\d+/.test(latestTag) &&
_loadedVersion && latestTag !== _loadedVersion;
if (!deployChanged && !releaseNewer) return;
// ── Show update badge alongside the title (title stays intact) ──
const badge = document.getElementById('header-update-badge');
if (!badge) return;
const versionLabel = deployChanged
? (serverVer ? `v${serverVer}` : 'Nuova versione')
: (latestTag ? `v${latestTag}` : 'Nuova versione');
const hideBadge = () => {
badge.style.display = 'none';
badge.innerHTML = '';
if (!deployChanged) localStorage.setItem(SEEN_KEY, publishedAt);
};
badge.innerHTML =
`<span class="header-update-badge-label">⬆️ ${versionLabel}</span>` +
`<button class="header-update-btn" onclick="window.location.reload()">Aggiorna</button>` +
`<button class="header-update-close" id="_header_update_close">✕</button>`;
badge.style.display = 'inline-flex';
document.getElementById('_header_update_close').onclick = (e) => {
e.stopPropagation();
hideBadge();
};
// Auto-hide after 60 s without marking as seen
setTimeout(() => { if (badge.style.display !== 'none') hideBadge(); }, 60000);
})
.catch(() => {});
}
// ── Global uncaught error handler ────────────────────────────────────────────
window.addEventListener('error', function(e) {
const msg = e.message || String(e.error);
// Ignore benign third-party noise
if (/Script error/i.test(msg)) return;
remoteLog('UNCAUGHT', `${msg} at ${e.filename}:${e.lineno}:${e.colno}`);
reportError({
type: 'uncaught-error',
message: msg,
stack: e.error?.stack || '',
context: { filename: e.filename, lineno: e.lineno, colno: e.colno },
});
});
window.addEventListener('unhandledrejection', function(e) {
const reason = e.reason;
const msg = reason instanceof Error ? reason.message : String(reason);
const stack = reason instanceof Error ? (reason.stack || '') : '';
remoteLog('UNHANDLED_PROMISE', msg);
reportError({
type: 'unhandled-promise',
message: msg,
stack: stack,
});
});
// ===== CONFIGURATION =====
const API_BASE = 'api/index.php';
// ===== SMART SCALE GATEWAY =====
// Connects to the Android BLE-WebSocket gateway and provides auto weight reading.
let _scaleEs = null; // EventSource for the SSE relay
let _scaleConnected = false;
let _scaleDevice = null;
let _scaleBattery = null;
let _scaleReconnectTimer = null;
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 _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
let _scaleRecipeAutoFillPaused = false; // pause flag for recipe-use modal only
let _scaleLastConfirmedGrams = null; // grams of last auto-confirmed weight (to detect product change)
let _scaleLastStableGrams = null; // last accepted stable reading in grams (for jitter filtering)
function _scaleToGrams(value, unit) {
if (!isFinite(value)) return null;
const u = (unit || 'g').toLowerCase();
if (u === 'kg') return value * 1000;
if (u === 'lbs' || u === 'lb') return value * 453.592;
if (u === 'oz') return value * 28.3495;
return value; // g / ml treated as grams-equivalent for stability filtering
}
function scaleInit() {
const s = getSettings();
const indicator = document.getElementById('scale-status-indicator');
if (!s.scale_enabled || !s.scale_gateway_url) {
if (indicator) indicator.style.display = 'none';
if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; }
return;
}
if (indicator) indicator.style.display = '';
_scaleConnect(s.scale_gateway_url);
}
function _scaleConnect(url) {
if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; }
if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; }
try {
// Connect via the PHP SSE relay so the HTTPS page is not blocked by mixed-content
_scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url));
_scaleEs.onopen = () => _scaleUpdateStatus('searching');
_scaleEs.onmessage = (evt) => {
try { _scaleOnMessage(JSON.parse(evt.data)); } catch(e) {}
};
_scaleEs.onerror = () => {
_scaleConnected = false;
_scaleDevice = null;
_scaleUpdateStatus('disconnected');
// EventSource auto-reconnects; no manual timer needed
};
} catch(e) {
_scaleUpdateStatus('error');
}
}
function _scaleOnMessage(msg) {
if (msg.type === 'status') {
_scaleConnected = msg.state === 'connected';
_scaleDevice = msg.device || null;
_scaleBattery = msg.battery ?? null;
_scaleUpdateStatus(_scaleConnected ? 'connected' : 'searching');
// Refresh all scale UI elements immediately so buttons/live-box appear
// without requiring a manual page refresh
updateScaleReadButtons();
} else if (msg.type === 'weight') {
// Ignore negative weight values (tare artifacts, sensor noise)
const rawValue = parseFloat(msg.value);
if (rawValue < 0) return;
// Ignore sub-gram jitter for stability decisions: only integer-gram changes matter.
let effectiveStable = !!msg.stable;
const grams = _scaleToGrams(rawValue, msg.unit);
if (grams !== null) {
if (effectiveStable) {
_scaleLastStableGrams = grams;
} else if (_scaleLastStableGrams !== null) {
if (Math.round(grams) === Math.round(_scaleLastStableGrams)) {
effectiveStable = true;
}
}
if (effectiveStable) {
_scaleLastStableGrams = grams;
}
}
const liveMsg = effectiveStable === msg.stable ? msg : { ...msg, stable: effectiveStable };
_scaleLatestWeight = liveMsg;
// 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'}${liveMsg.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'}${liveMsg.stable ? ' ✓' : ' …'}`;
// Always update the persistent live box on the use page (every message, stable or not)
_scaleUpdateLiveBox(liveMsg);
// If weight is NOT stable: stop any running timer/bar but keep the sentinel value.
// The sentinel is reset only when a genuinely different stable value arrives.
if (!liveMsg.stable) {
_cancelScaleTimersOnly();
}
// Fulfil pending callback on stable reading
if (liveMsg.stable && _scaleWeightCallback) {
const cb = _scaleWeightCallback;
_scaleWeightCallback = null;
cb(liveMsg);
}
// Drive stability logic on use page
if (liveMsg.stable && _currentPageId === 'use') {
_scaleAutoFillUse(liveMsg);
}
// Same for recipe-use modal
if (liveMsg.stable && document.getElementById('ruse-quantity') && !_scaleRecipeAutoFillPaused) {
_scaleAutoFillRecipeUse(liveMsg);
}
}
}
/**
* Returns the liquid density (g/ml) for a product based on its name/category.
* Used to convert scale grams → ml for products stored in ml.
*/
function _scaleDensityForProduct(product) {
const n = (product?.name || '').toLowerCase();
const cat = (product?.category || '').toLowerCase();
// Oils (lighter than water)
if (/olio.oliva|olive.oil/.test(n)) return 0.91;
if (/olio.girasole|sunflower.oil/.test(n)) return 0.92;
if (/\bolio\b|\boil\b/.test(n)) return 0.92;
// Spirits / alcohol (lighter than water)
if (/vodka|whisky|whiskey|grappa|rum|gin\b/.test(n)) return 0.94;
// Vinegar, wine, beer (close to water)
if (/aceto|vinegar/.test(n)) return 1.01;
if (/\bvino\b|\bwine\b|\bbirra\b|\bbeer\b/.test(n)) return 1.00;
// Milk & dairy liquids
if (/\blatte\b|\bmilk\b/.test(n)) return 1.03;
if (/panna|cream/.test(n)) return 1.01;
if (/yogurt/.test(n)) return 1.05;
// Juice
if (/succo|juice|spremuta/.test(n)) return 1.04;
// Honey / syrups (dense)
if (/miele|honey|sciroppo|syrup/.test(n)) return 1.40;
// Water / sparkling
if (/\bacqua\b|\bwater\b/.test(n)) return 1.00;
// Category-level fallbacks
if (/latticin/.test(cat)) return 1.03;
if (/condiment/.test(cat)) return 0.92; // likely oil-based
if (/bevand/.test(cat)) return 1.00;
return 1.00; // safe default (water)
}
/**
* Update the persistent live-weight box on the use page (called on every weight message).
* Shows raw scale reading in real time regardless of stability or unit compatibility.
*/
function _scaleUpdateLiveBox(msg) {
const box = document.getElementById('scale-live-box');
if (!box) return;
const s = getSettings();
const active = s.scale_enabled && s.scale_gateway_url && _scaleConnected &&
_currentPageId === 'use';
box.style.display = active ? '' : 'none';
if (!active) return;
const raw = parseFloat(msg.value);
const rawUnit = (msg.unit || 'kg').toLowerCase();
// Convert to grams for the < 10 g threshold check
let gForCheck = isFinite(raw) ? raw : 0;
if (rawUnit === 'kg') gForCheck = raw * 1000;
if (rawUnit === 'lbs' || rawUnit === 'lb') gForCheck = raw * 453.592;
const valEl = document.getElementById('scale-live-val');
const lblEl = document.getElementById('scale-live-label');
if (isFinite(raw) && gForCheck < 10 && gForCheck > 0) {
// 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 = t('scale.low_weight');
} else {
box.classList.remove('scale-low-weight');
const stIcon = msg.stable ? ' ✓' : ' …';
// Show converted ML if target unit is ml (instead of raw grams)
let displayVal = `${isFinite(raw) ? raw : '—'} ${msg.unit || 'kg'}`;
let targetUnit = null;
if (_useConfMode && _useConfMode._activeUnit === 'sub') {
targetUnit = (_useConfMode.packageUnit || '').toLowerCase();
} else {
targetUnit = _useNormalUnit;
}
if (targetUnit === 'ml' && rawUnit !== 'ml' && isFinite(raw) && raw > 0) {
let grams = raw;
if (rawUnit === 'kg') grams = raw * 1000;
else if (rawUnit === 'lbs' || rawUnit === 'lb') grams = raw * 453.592;
else if (rawUnit === 'oz') grams = raw * 28.3495;
const density = _scaleDensityForProduct(currentProduct);
const ml = Math.round(grams / density);
displayVal = `${ml} ml`;
}
if (valEl) valEl.textContent = displayVal + stIcon;
if (lblEl) {
lblEl.textContent = '';
}
}
}
/**
* Auto-fill: called on every STABLE weight message while on the use page.
* - Updates the live box (conversion hint)
* - After 5 s of stable unchanged value: fills the input and starts the confirm progress bar
* - If value changes: resets the 5-s stability wait
* - If user dismissed (touch/edit): does nothing for the same value; resets on value change
*/
function _scaleAutoFillUse(msg) {
if (!msg) return;
// Determine target unit
let unit;
if (_useConfMode && _useConfMode._activeUnit === 'sub') {
unit = (_useConfMode.packageUnit || '').toLowerCase();
} else {
unit = _useNormalUnit;
}
if (unit !== 'g' && unit !== 'ml') return; // pz / conf-unit: ignore
const rawVal = parseFloat(msg.value);
if (!isFinite(rawVal) || rawVal <= 0) return;
const srcUnit = (msg.unit || '').toLowerCase();
// Normalise to grams
let grams;
let scaleAlreadyMl = false;
if (srcUnit === 'g') grams = rawVal;
else if (srcUnit === 'kg') grams = rawVal * 1000;
else if (srcUnit === 'lbs' || srcUnit === 'lb') grams = rawVal * 453.592;
else if (srcUnit === 'oz') grams = rawVal * 28.3495;
else if (srcUnit === 'ml') { grams = rawVal; scaleAlreadyMl = true; }
else grams = rawVal;
// Reject if raw grams < 10 (piatto vuoto / tara / rumore)
if (grams < 10) {
_cancelScaleStabilityWait(); // stop bar only; keep sentinel & userDismissed
return;
}
// Reject if weight hasn't changed enough from last confirmed reading (same product still on scale)
if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 10) {
return;
}
// Convert to target unit
let val;
let hintExtra = '';
if (unit === 'g') {
if (scaleAlreadyMl) {
const density = _scaleDensityForProduct(currentProduct);
val = Math.round(grams * density);
if (density !== 1.00) hintExtra = ' ' + t('scale.density_hint', { density });
} else {
val = Math.round(grams);
}
} else {
if (scaleAlreadyMl) {
val = Math.round(grams);
} else {
const density = _scaleDensityForProduct(currentProduct);
val = Math.round(grams / density);
if (density !== 1.00) hintExtra = ' ' + t('scale.density_hint', { density });
}
}
// Reject if converted value < 10 (density edge case)
if (val < 10) {
_cancelScaleStabilityWait();
return;
}
if (val !== _scaleStabilityVal) {
// New (different) weight → clear dismissal, restart stability wait
_scaleStabilityVal = val;
_scaleUserDismissed = false;
_cancelScaleTimersOnly();
_startScaleStabilityWait(() => {
// Fill the input after 5 s of stable weight
const inp = document.getElementById('use-quantity');
if (inp) inp.value = val;
// Start the 5-s confirm progress bar
_startScaleAutoConfirm(() => {
_scaleLastConfirmedGrams = grams;
const form = document.querySelector('#page-use form');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}, 'btn-use-submit');
});
} else if (!_scaleUserDismissed && !_scaleStabilityTimer && !_scaleAutoConfirmTimer) {
// Same value, not dismissed, no timer running (e.g. after brief !stable interruption)
// → restart stability wait so it eventually completes
_cancelScaleTimersOnly();
_startScaleStabilityWait(() => {
const inp = document.getElementById('use-quantity');
if (inp) inp.value = val;
_startScaleAutoConfirm(() => {
_scaleLastConfirmedGrams = grams;
const form = document.querySelector('#page-use form');
if (form) form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}, 'btn-use-submit');
});
}
// Same value + dismissed → do nothing (user explicitly dismissed this value)
// Same value + timer running → do nothing (already counting down)
}
/**
* Auto-fill ruse-quantity input from a stable scale reading (recipe-use modal).
*/
function _scaleAutoFillRecipeUse(msg) {
if (!msg) return;
let unit;
if (_recipeUseConfMode && _recipeUseConfMode._activeUnit === 'sub') {
unit = (_recipeUseConfMode.packageUnit || '').toLowerCase();
} else {
unit = _recipeUseNormalUnit;
}
if (unit !== 'g' && unit !== 'ml') return;
const rawVal = parseFloat(msg.value);
if (!isFinite(rawVal) || rawVal <= 0) return;
const srcUnit = (msg.unit || '').toLowerCase();
let grams;
let scaleAlreadyMl = false;
if (srcUnit === 'g') grams = rawVal;
else if (srcUnit === 'kg') grams = rawVal * 1000;
else if (srcUnit === 'lbs' || srcUnit === 'lb') grams = rawVal * 453.592;
else if (srcUnit === 'oz') grams = rawVal * 28.3495;
else if (srcUnit === 'ml') { grams = rawVal; scaleAlreadyMl = true; }
else grams = rawVal;
let val;
let hintExtra = '';
if (unit === 'g') {
if (scaleAlreadyMl) {
const density = _scaleDensityForProduct(currentProduct);
val = Math.round(grams * density);
if (density !== 1.00) hintExtra = ' ' + t('scale.density_hint', { density });
} else {
val = Math.round(grams);
}
} else {
if (scaleAlreadyMl) {
val = Math.round(grams);
} else {
const density = _scaleDensityForProduct(currentProduct);
val = Math.round(grams / density);
if (density !== 1.00) hintExtra = ' ' + t('scale.density_hint', { density });
}
}
// Update live box in modal — show the already-converted value in the target unit
const livVal = document.getElementById('ruse-scale-live-val');
const livLabel = document.getElementById('ruse-scale-live-label');
const livStatus = document.getElementById('ruse-scale-live-status');
if (livVal) {
// val is already converted to target unit (g or ml); show it directly
if (val >= 10) {
livVal.textContent = `${val} ${unit}`;
} else {
// val not usable yet — show raw reading
livVal.textContent = `${msg.value} ${msg.unit || 'kg'}`;
}
}
if (livStatus) livStatus.textContent = msg.stable ? t('scale.stable') : '…';
// Update live hint in modal with the raw scale reading always
const hint = document.getElementById('ruse-scale-hint');
if (hint) {
hint.textContent = `⚖️ Bilancia: ${msg.value} ${msg.unit || 'kg'}${msg.stable ? ' ✓' : ' …'}`;
if (unit === 'ml' && srcUnit !== 'ml') {
hint.textContent += ' ' + t('scale.ml_hint');
}
hint.style.display = '';
}
if (val < 10) {
_cancelScaleStabilityWait(); // stop bar only; keep sentinel
if (livLabel) livLabel.textContent = t('scale.weight_too_low');
return;
}
// Reject if weight hasn't changed enough from last confirmed reading
if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 10) {
return;
}
if (val !== _scaleStabilityVal) {
_scaleStabilityVal = val;
_scaleUserDismissed = false;
_cancelScaleTimersOnly();
if (livLabel) livLabel.textContent = t('scale.weight_detected');
// Hide confirm bar when new value arrives
const confirmWrap = document.getElementById('ruse-scale-confirm-wrap');
if (confirmWrap) confirmWrap.style.display = 'none';
_startScaleStabilityWait(() => {
const inp = document.getElementById('ruse-quantity');
if (inp) inp.value = val;
if (hint) {
hint.textContent = `⚖️ Peso bilancia: ${val} ${unit}${hintExtra}`;
hint.style.display = '';
}
if (livLabel) livLabel.textContent = t('scale.auto_confirm', { val, unit });
if (livVal) livVal.style.color = '#22c55e';
const confirmWrap2 = document.getElementById('ruse-scale-confirm-wrap');
if (confirmWrap2) { confirmWrap2.style.display = ''; }
const confirmBar = document.getElementById('ruse-scale-confirm-bar');
if (confirmBar) confirmBar.style.width = '100%';
_startScaleAutoConfirm(() => {
_scaleLastConfirmedGrams = grams;
if (livVal) livVal.style.color = '';
submitRecipeUse(false);
}, 'btn-ruse-submit');
});
} else if (!_scaleUserDismissed && !_scaleStabilityTimer && !_scaleAutoConfirmTimer) {
_cancelScaleTimersOnly();
if (livLabel) livLabel.textContent = t('scale.weight_detected');
_startScaleStabilityWait(() => {
const inp = document.getElementById('ruse-quantity');
if (inp) inp.value = val;
if (livLabel) livLabel.textContent = t('scale.auto_confirm', { val, unit });
if (livVal) livVal.style.color = '#22c55e';
const confirmWrap3 = document.getElementById('ruse-scale-confirm-wrap');
if (confirmWrap3) confirmWrap3.style.display = '';
const confirmBar2 = document.getElementById('ruse-scale-confirm-bar');
if (confirmBar2) confirmBar2.style.width = '100%';
_startScaleAutoConfirm(() => {
_scaleLastConfirmedGrams = grams;
if (livVal) livVal.style.color = '';
submitRecipeUse(false);
}, 'btn-ruse-submit');
});
}
}
/** Cancel auto-confirm countdown on any screen press (touch = dismiss). */
function _cancelScaleAutoConfirmOnTouch() {
_cancelScaleAutoConfirm(true);
}
/**
* Cancel timers, animations and button styles — does NOT touch _scaleStabilityVal
* or _scaleUserDismissed. Use this when weight goes unstable so the sentinel
* is preserved and the same value can resume counting when stability returns.
*/
function _cancelScaleTimersOnly() {
if (_scaleAutoConfirmTimer) { clearTimeout(_scaleAutoConfirmTimer); _scaleAutoConfirmTimer = null; }
if (_scaleAutoConfirmRAF) { cancelAnimationFrame(_scaleAutoConfirmRAF); _scaleAutoConfirmRAF = null; }
_cancelScaleStabilityWait();
const useBtn = document.getElementById('btn-use-submit');
const ruseBtn = document.getElementById('btn-ruse-submit');
if (useBtn) useBtn.style.background = '';
if (ruseBtn) ruseBtn.style.background = '';
// Reset modal confirm bar and live val colour
const confirmBar = document.getElementById('ruse-scale-confirm-bar');
const livVal = document.getElementById('ruse-scale-live-val');
const confirmWrap = document.getElementById('ruse-scale-confirm-wrap');
if (confirmBar) { confirmBar.style.width = '100%'; }
if (confirmWrap) confirmWrap.style.display = 'none';
if (livVal) livVal.style.color = '';
const livLabel = document.getElementById('ruse-scale-live-label');
if (livLabel && livLabel.textContent.startsWith('✅')) {
livLabel.textContent = t('scale.cancelled_replace');
}
document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true);
}
/**
* Full cancel: stops timers AND updates state flags.
* @param {boolean} fromTouch true = user tapped → set userDismissed
* false = programmatic (page nav, closeModal, oninput) → reset sentinel
*/
function _cancelScaleAutoConfirm(fromTouch) {
_cancelScaleTimersOnly();
if (fromTouch) {
_scaleUserDismissed = true;
} else {
_scaleStabilityVal = null;
_scaleLastConfirmedGrams = null;
}
}
/** Stop the stability wait and reset its progress bar(s). */
function _cancelScaleStabilityWait() {
if (_scaleStabilityTimer) { clearTimeout(_scaleStabilityTimer); _scaleStabilityTimer = null; }
if (_scaleStabilityRAF) { cancelAnimationFrame(_scaleStabilityRAF); _scaleStabilityRAF = null; }
const bar = document.getElementById('scale-live-progress-bar');
const bar2 = document.getElementById('ruse-scale-progress-bar');
if (bar) bar.style.width = '0%';
if (bar2) bar2.style.width = '0%';
}
/**
* Start a 10-second stability wait with an animated progress bar.
* Updates both #scale-live-progress-bar (use page) and #ruse-scale-progress-bar (recipe modal).
* Calls onStable() when weight unchanged for 5 s.
*/
function _startScaleStabilityWait(onStable) {
_cancelScaleStabilityWait();
const duration = 5000;
const start = performance.now();
const bar = document.getElementById('scale-live-progress-bar');
const bar2 = document.getElementById('ruse-scale-progress-bar');
function tick() {
const pct = Math.min(100, ((performance.now() - start) / duration) * 100);
if (bar) bar.style.width = pct + '%';
if (bar2) bar2.style.width = pct + '%';
if (pct < 100) { _scaleStabilityRAF = requestAnimationFrame(tick); }
}
_scaleStabilityRAF = requestAnimationFrame(tick);
_scaleStabilityTimer = setTimeout(() => {
_scaleStabilityTimer = null;
if (_scaleStabilityRAF) { cancelAnimationFrame(_scaleStabilityRAF); _scaleStabilityRAF = null; }
if (bar) bar.style.width = '0%';
if (bar2) bar2.style.width = '0%';
onStable();
}, duration);
}
function _startScaleAutoConfirm(onConfirm, btnId) {
if (_scaleAutoConfirmTimer) { clearTimeout(_scaleAutoConfirmTimer); _scaleAutoConfirmTimer = null; }
if (_scaleAutoConfirmRAF) { cancelAnimationFrame(_scaleAutoConfirmRAF); _scaleAutoConfirmRAF = null; }
const btn = btnId ? document.getElementById(btnId) : null;
const baseBg = btn ? getComputedStyle(btn).backgroundColor : '';
// Also update the modal countdown bar if present
const ruseCountdownBar = document.getElementById('ruse-scale-confirm-bar');
const duration = 5000;
const start = performance.now();
function tick() {
const elapsed = performance.now() - start;
const pct = Math.min(100, (elapsed / duration) * 100);
// Reverse (countdown): button fill shrinks from right to left
if (btn) {
btn.style.background =
`linear-gradient(to left, rgba(255,255,255,0.35) ${100 - pct}%, rgba(255,255,255,0) ${100 - pct}%), ${baseBg}`;
}
// Modal countdown progress bar shrinks
if (ruseCountdownBar) ruseCountdownBar.style.width = (100 - pct) + '%';
if (elapsed < duration) { _scaleAutoConfirmRAF = requestAnimationFrame(tick); }
}
_scaleAutoConfirmRAF = requestAnimationFrame(tick);
_scaleAutoConfirmTimer = setTimeout(() => {
_scaleAutoConfirmTimer = null;
if (btn) btn.style.background = '';
if (ruseCountdownBar) ruseCountdownBar.style.width = '0%';
document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true);
onConfirm();
}, duration);
document.addEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true);
}
/**
* Update the scale status indicator icon/class.
*/
function _scaleUpdateStatus(state) {
const el = document.getElementById('scale-status-indicator');
if (!el) return;
el.className = `header-btn scale-status-indicator scale-status-${state}`;
const labels = {
connected: `⚖️ ${t('scale.status_connected')}${_scaleDevice ? ': ' + _scaleDevice : ''}`,
searching: `⚖️ ${t('scale.status_searching')}`,
disconnected: `⚖️ ${t('scale.status_disconnected')}`,
error: `⚖️ ${t('scale.status_error')}`,
};
el.title = labels[state] || '';
}
/**
* Show a brief toast with the current scale connection status when the icon is tapped.
*/
function _scaleShowInfo() {
const state = _scaleConnected ? 'connected' : 'disconnected';
const msgs = {
connected: `⚖️ ${t('scale.status_connected')}${_scaleDevice ? ': ' + _scaleDevice : ''}${_scaleBattery != null ? ' 🔋' + _scaleBattery + '%' : ''}`,
searching: `⚖️ ${t('scale.status_searching')}`,
disconnected: `⚖️ ${t('scale.status_disconnected')}`,
error: `⚖️ ${t('scale.status_error')}`,
};
const el = document.getElementById('scale-status-indicator');
const cls = el ? [...el.classList].find(c => c.startsWith('scale-status-') && c !== 'scale-status-indicator') : null;
const key = cls ? cls.replace('scale-status-', '') : state;
showToast(msgs[key] || msgs[state], key === 'connected' ? 'success' : 'info');
}
/**
* Show the scale reading modal and wait for a stable weight, then populate the input.
* @param {string} targetInputId — ID of the <input> to fill
* @param {Function} getUnit — function that returns the current unit string ('g', 'ml', 'kg')
*/
function readScaleWeight(targetInputId, getUnit) {
if (!_scaleConnected) {
showToast('⚖️ ' + t('scale.not_connected'), 'error');
return;
}
const unit = typeof getUnit === 'function' ? getUnit() : getUnit;
_scaleShowReadingModal(targetInputId, unit);
_scaleWeightCallback = (msg) => {
let val = parseFloat(msg.value);
const srcUnit = (msg.unit || 'kg').toLowerCase();
// Convert to target unit
if (srcUnit === 'kg' && unit === 'g') val = Math.round(val * 1000);
if (srcUnit === 'g' && unit === 'kg') val = +(val / 1000).toFixed(3);
if (srcUnit === 'lbs'|| srcUnit === 'lb') {
val = val * 453.592;
if (unit === 'kg') val = +(val / 1000).toFixed(2); else val = Math.round(val);
}
if (srcUnit === 'kg' && unit === 'ml') val = Math.round(val * 1000); // approximate (water density)
const inp = document.getElementById(targetInputId);
if (inp) { inp.value = val; inp.dispatchEvent(new Event('input')); }
closeModal();
showToast(`⚖️ ${val} ${unit}`, 'success');
};
// 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">
<h3>⚖️ ${t('scale.reading_title')}</h3>
<button class="modal-close" onclick="closeModal(); _scaleWeightCallback = null;">✕</button>
</div>
<div style="padding:16px;text-align:center">
<p style="margin-bottom:16px">${t('scale.place_on_scale')}</p>
<div id="scale-reading-live" class="scale-reading-live">— — —</div>
<p class="settings-hint" style="margin-top:12px">${t('scale.waiting_stable')}</p>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
/**
* Show/hide "⚖️ Leggi dalla bilancia" buttons based on current settings and unit.
* Called after unit change or when navigating to the add/use form.
*/
function updateScaleReadButtons() {
const s = getSettings();
const ready = s.scale_enabled && s.scale_gateway_url;
const btnAdd = document.getElementById('btn-scale-add');
if (btnAdd) {
const addUnit = document.getElementById('add-unit')?.value;
btnAdd.style.display = (ready && (addUnit === 'g' || addUnit === 'ml')) ? '' : 'none';
}
const btnUse = document.getElementById('btn-scale-use');
if (btnUse) {
btnUse.style.display = (ready && (_useNormalUnit === 'g' || _useNormalUnit === 'ml')) ? '' : 'none';
}
// Live box: visible when scale enabled + connected + on use page + compatible unit
const liveBox = document.getElementById('scale-live-box');
if (liveBox) {
const isWeightUnit = (_useNormalUnit === 'g' || _useNormalUnit === 'ml') ||
(_useConfMode && (_useConfMode.packageUnit === 'g' || _useConfMode.packageUnit === 'ml'));
liveBox.style.display = (ready && _scaleConnected && _currentPageId === 'use' && isWeightUnit) ? '' : 'none';
}
}
function onScaleEnabledChange() {
const s = getSettings();
const el = document.getElementById('setting-scale-enabled');
s.scale_enabled = el ? el.checked : false;
saveSettingsToStorage(s);
scaleInit();
updateScaleReadButtons();
}
function testScaleConnection() {
const urlEl = document.getElementById('setting-scale-url');
const statusEl = document.getElementById('scale-test-status');
if (!urlEl || !statusEl) return;
const url = urlEl.value.trim();
if (!url) { showToast(t('scale.no_url'), 'error'); return; }
statusEl.textContent = t('scale.testing');
statusEl.className = 'settings-status';
statusEl.style.display = 'block';
const ac = new AbortController();
const timeout = setTimeout(() => {
ac.abort();
statusEl.textContent = '❌ ' + t('scale.timeout');
statusEl.className = 'settings-status error';
}, 8000);
fetch('api/scale_ping.php?url=' + encodeURIComponent(url), { signal: ac.signal })
.then(r => r.json())
.then(data => {
clearTimeout(timeout);
if (data.ok) {
statusEl.textContent = '✅ ' + t('scale.connected_ok');
statusEl.className = 'settings-status success';
} else {
statusEl.textContent = '❌ ' + (data.error || t('scale.error_connect'));
statusEl.className = 'settings-status error';
}
})
.catch(e => {
clearTimeout(timeout);
if (e.name !== 'AbortError') {
statusEl.textContent = '❌ ' + t('scale.error_connect');
statusEl.className = 'settings-status error';
}
});
}
async function discoverScaleGateway() {
const btn = document.getElementById('btn-scale-discover');
const status = document.getElementById('scale-discover-status');
if (!btn || !status) return;
btn.disabled = true;
btn.textContent = '⏳';
status.style.display = 'block';
status.textContent = '🔍 Scanning local network for scale gateway…';
try {
const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000) });
const data = await res.json();
if (data.error) {
status.textContent = '❌ ' + data.error;
} else if (data.found && data.found.length > 0) {
const url = data.found[0];
const urlEl = document.getElementById('setting-scale-url');
if (urlEl) urlEl.value = url;
status.textContent = '✅ Gateway found: ' + url + (data.found.length > 1 ? ' (+' + (data.found.length - 1) + ' more)' : '');
status.style.color = 'var(--color-success, #059669)';
// Auto-save
const s = getSettings();
s.scale_gateway_url = url;
saveSettingsToStorage(s);
scaleInit();
} else {
status.textContent = '❌ No gateway found on ' + (data.subnet || 'local network') + '. Make sure the Android app is running and on the same Wi-Fi.';
}
} catch(e) {
status.textContent = '❌ Discovery failed: ' + (e.message || 'timeout');
}
btn.disabled = false;
btn.textContent = '🔍 Auto';
}
// ===== i18n TRANSLATION SYSTEM =====
let _i18nStrings = null; // current language translations (flat)
let _i18nFallback = null; // Italian fallback (flat)
let _currentLang = localStorage.getItem('evershelf_lang') || navigator.language?.slice(0, 2) || 'it';
const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch' };
if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'it';
// Flatten nested JSON: { a: { b: "x" } } → { "a.b": "x" }
function _flattenI18n(obj, prefix = '') {
const result = {};
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
Object.assign(result, _flattenI18n(v, key));
} else {
result[key] = v;
}
}
return result;
}
// Translation function: t('toast.thrown_away', {name: 'Latte'})
function t(key, params) {
let str = (_i18nStrings && _i18nStrings[key]) || (_i18nFallback && _i18nFallback[key]) || key;
if (params) {
for (const [k, v] of Object.entries(params)) {
str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
}
}
return str;
}
// Load translations from JSON files
async function loadTranslations(lang) {
lang = lang || _currentLang;
try {
// Always load Italian as fallback
if (!_i18nFallback) {
const fbRes = await fetch(`translations/it.json?v=${Date.now()}`);
if (fbRes.ok) _i18nFallback = _flattenI18n(await fbRes.json());
}
if (lang === 'it') {
_i18nStrings = _i18nFallback;
} else {
const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${Date.now()}`);
if (res.ok) _i18nStrings = _flattenI18n(await res.json());
else _i18nStrings = _i18nFallback;
}
_currentLang = lang;
localStorage.setItem('evershelf_lang', lang);
_applyI18nToLabels();
translatePage();
} catch (e) {
console.warn('i18n: Failed to load translations for', lang, e);
_i18nStrings = _i18nFallback;
}
}
// Update LOCATIONS / SHOPPING_SECTIONS labels from translations
function _applyI18nToLabels() {
if (!_i18nStrings) return;
for (const key of Object.keys(LOCATIONS)) {
const tKey = `locations.${key}`;
if (_i18nStrings[tKey]) LOCATIONS[key].label = _i18nStrings[tKey];
}
for (const sec of SHOPPING_SECTIONS) {
const tKey = `shopping_sections.${sec.key}`;
if (_i18nStrings[tKey]) sec.label = _i18nStrings[tKey];
}
}
// Translate all elements with data-i18n attributes
function translatePage() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (key) el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-html]').forEach(el => {
const key = el.getAttribute('data-i18n-html');
if (key) el.innerHTML = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (key) el.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (key) el.title = t(key);
});
// Update HTML lang attribute
document.documentElement.lang = _currentLang;
// Populate language selector if present
_populateLanguageSelector();
}
// Populate the language selector dropdown
function _populateLanguageSelector() {
const sel = document.getElementById('setting-language');
if (!sel) return;
sel.innerHTML = '';
for (const [code, name] of Object.entries(_SUPPORTED_LANGS)) {
const opt = document.createElement('option');
opt.value = code;
opt.textContent = name;
if (code === _currentLang) opt.selected = true;
sel.appendChild(opt);
}
}
// Change language and reload the page
function changeLanguage(lang) {
if (lang === _currentLang) return;
localStorage.setItem('evershelf_lang', lang);
location.reload();
}
const LOCATIONS = {
'dispensa': { icon: '🗄️', label: t('locations.dispensa') },
'frigo': { icon: '🧊', label: t('locations.frigo') },
'freezer': { icon: '❄️', label: t('locations.freezer') },
'altro': { icon: '📦', label: t('locations.altro') },
};
const CATEGORY_ICONS = {
'latticini': '🥛', 'carne': '🥩', 'pesce': '🐟', 'frutta': '🍎',
'verdura': '🥬', 'pasta': '🍝', 'pane': '🍞', 'surgelati': '🧊',
'bevande': '🥤', 'condimenti': '🧂', 'snack': '🍪', 'conserve': '🥫',
'cereali': '🌾', 'igiene': '🧴', 'pulizia': '🧹', 'altro': '📦'
};
// Auto-detect location based on category and product name
const CATEGORY_LOCATION = {
'latticini': 'frigo', 'carne': 'frigo', 'pesce': 'frigo',
'frutta': 'frigo', 'verdura': 'frigo', 'surgelati': 'freezer',
'pasta': 'dispensa', 'pane': 'dispensa', 'bevande': 'dispensa',
'condimenti': 'dispensa', 'snack': 'dispensa', 'conserve': 'dispensa',
'cereali': 'dispensa', 'igiene': 'altro', 'pulizia': 'altro', 'altro': 'dispensa'
};
// Shopping section (reparto) map — groups categories into grocery departments
const SHOPPING_SECTIONS = [
{ key: 'frutta_verdura', icon: '🥬', label: t('shopping_sections.frutta_verdura'), cats: new Set(['frutta','verdura']) },
{ key: 'carne_pesce', icon: '🥩', label: t('shopping_sections.carne_pesce'), cats: new Set(['carne','pesce']) },
{ key: 'latticini', icon: '🥛', label: t('shopping_sections.latticini'), cats: new Set(['latticini']) },
{ key: 'pane_dolci', icon: '🍞', label: t('shopping_sections.pane_dolci'), cats: new Set(['pane','snack','cereali']) },
{ key: 'pasta', icon: '🍝', label: t('shopping_sections.pasta'), cats: new Set(['pasta']) },
{ key: 'conserve', icon: '🥫', label: t('shopping_sections.conserve'), cats: new Set(['conserve','condimenti']) },
{ key: 'surgelati', icon: '❄️', label: t('shopping_sections.surgelati'), cats: new Set(['surgelati']) },
{ key: 'bevande', icon: '🥤', label: t('shopping_sections.bevande'), cats: new Set(['bevande']) },
{ key: 'pulizia_igiene', icon: '🧴', label: t('shopping_sections.pulizia_igiene'), cats: new Set(['igiene','pulizia']) },
{ key: 'altro', icon: '📦', label: t('shopping_sections.altro'), cats: new Set(['altro']) },
];
function getItemSection(name) {
const cat = guessCategoryFromName(name) || 'altro';
for (const s of SHOPPING_SECTIONS) { if (s.cats.has(cat)) return s; }
return SHOPPING_SECTIONS[SHOPPING_SECTIONS.length - 1];
}
const URGENCY_WEIGHT = { critical: 4, high: 3, medium: 2, low: 1 };
const URGENCY_BG = {
critical: 'rgba(194,65,12,0.14)',
high: 'rgba(234,88,12,0.09)',
medium: 'rgba(245,158,11,0.07)',
low: 'rgba(34,197,94,0.05)',
};
// Map Open Food Facts categories to local categories
function mapToLocalCategory(ofCategory, productName) {
if (!ofCategory) {
// No category tag — try to guess from product name
return guessCategoryFromName(productName || '');
}
const cat = ofCategory.toLowerCase();
// Direct match with our local keys
for (const key of Object.keys(CATEGORY_ICONS)) {
if (cat === key) return key;
}
// Handle specific Open Food Facts tags FIRST (before generic regex)
// "plant-based-foods-and-beverages" is a catch-all — use product name to decide
if (/plant-based-foods/.test(cat)) {
return guessCategoryFromName(productName || '');
}
// "beverages-and-beverages-preparations" = actual beverages
if (/^en:beverages/.test(cat)) return 'bevande';
// sweeteners = condimenti
if (/sweetener|dolcific/.test(cat)) return 'condimenti';
// Specific tag patterns
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte/.test(cat)) return 'latticini';
if (/meat|viande|carne|sausage|salum|prosciutt/.test(cat)) return 'carne';
if (/fish|poisson|pesce|seafood|tuna|tonno|salmone/.test(cat)) return 'pesce';
if (/fruit|frutta|juice|succo|apple|banana/.test(cat)) return 'frutta';
if (/vegetable|verdur|legum|salad|insalat|tomato|pomodor/.test(cat)) return 'verdura';
if (/pasta|rice|riso|noodle|spaghetti|penne|grain/.test(cat)) return 'pasta';
if (/bread|pane|forno|biscott|toast|cracker|grissini|fette/.test(cat)) return 'pane';
if (/frozen|surgelé|surgel|gelat/.test(cat)) return 'surgelati';
if (/sauce|condiment|oil|olio|vinegar|aceto|mayo|ketchup|spice|salt|sugar|zuccher/.test(cat)) return 'condimenti';
if (/snack|chip|crisp|chocolate|cioccolat|candy|biscuit|cookie|wafer|merendine|patatine/.test(cat)) return 'snack';
if (/conserve|canned|can|pelati|passata|preserve|jam|marmellat|miele|honey/.test(cat)) return 'conserve';
if (/cereal|muesli|granola|oat|fiocchi/.test(cat)) return 'cereali';
if (/hygiene|soap|shampoo|igien|dentifricio|deodorant/.test(cat)) return 'igiene';
if (/clean|detergent|pulizia|detersiv/.test(cat)) return 'pulizia';
// Beverage check LAST (to avoid false matches on compound tags)
if (/^(?!.*plant-based).*(beverage|drink|boisson|bevand|water|acqua|beer|birra|wine|vino|coffee|caffè|tea\b)/.test(cat)) return 'bevande';
return 'altro';
}
// Guess a local category purely from product name
function guessCategoryFromName(name) {
if (!name) return 'altro';
const n = name.toLowerCase();
// Pasta & Rice
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio/.test(n)) return 'pasta';
// Pane & Forno
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli/.test(n)) return 'pane';
// Conserve
if (/passata|pelati|pomodoro|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive/.test(n)) return 'conserve';
// Condimenti
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa/.test(n)) return 'condimenti';
// Bevande
if (/acqua|birra|vino|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|latte\b/.test(n)) return 'bevande';
// Latticini
if (/latte\b|yogurt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b/.test(n)) return 'latticini';
// Carne
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck/.test(n)) return 'carne';
// Pesce
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe/.test(n)) return 'pesce';
// Frutta
if (/mela|mele|banana|arancia|pera|fragola|uva|kiwi|limone|frutta/.test(n)) return 'frutta';
// Verdura
if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata/.test(n)) return 'verdura';
// Surgelati
if (/surgelat|frozen|findus|4.salti|gelato/.test(n)) return 'surgelati';
// Snack
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini/.test(n)) return 'snack';
// Cereali
if (/cereali|muesli|fiocchi|granola|polenta/.test(n)) return 'cereali';
// Igiene / Pulizia
if (/sapone|shampoo|dentifricio|deodorante/.test(n)) return 'igiene';
if (/detersivo|pulito|sgrassatore/.test(n)) return 'pulizia';
return 'altro';
}
// ─────────────────────────────────────────────────────────────────────────────
// Embedding-based category classifier (async, @xenova/transformers)
// ─────────────────────────────────────────────────────────────────────────────
// Canonical descriptions for each local category (used as embedding anchors).
const _CATEGORY_DESCRIPTIONS = {
latticini: 'latte yogurt formaggio burro panna mozzarella latticini dairy',
carne: 'carne pollo manzo maiale vitello prosciutto salame bresaola meat',
pesce: 'pesce tonno salmone merluzzo gamberi seafood fish',
frutta: 'frutta mela banana arancia pera fragola uva kiwi fruit',
verdura: 'verdura insalata zucchina carota cipolla spinaci tomato vegetables',
pasta: 'pasta spaghetti penne fusilli riso risotto noodles rice',
pane: 'pane fette biscottate grissini cracker toast bread bakery',
surgelati: 'surgelati congelato frozen gelato ice cream',
bevande: 'acqua birra vino succo caffè tè bevande drinks beverages',
condimenti: 'olio aceto sale zucchero farina ketchup maionese senape spezie condiments',
snack: 'biscotti cioccolato patatine snack caramelle wafer merendine',
conserve: 'conserve pelati passata marmellata miele legumi ceci beans canned',
cereali: 'cereali muesli granola fiocchi d\'avena oat breakfast cereal',
igiene: 'sapone shampoo dentifricio deodorante igiene personale hygiene',
pulizia: 'detersivo detergente pulizia casa sgrassatore cleaning',
altro: 'prodotto generico varie altro miscellaneous',
};
// In-memory cache: productName → category (avoids re-embedding the same product)
const _embeddingCache = new Map();
/**
* Cosine similarity between two Float32Array vectors.
*/
function _cosineSim(a, b) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
}
/**
* Mean-pool a [1, tokens, dims] tensor → Float32Array of length dims.
*/
function _meanPool(tensor) {
const [, tokens, dims] = tensor.dims;
const data = tensor.data;
const out = new Float32Array(dims);
for (let t = 0; t < tokens; t++) {
for (let d = 0; d < dims; d++) {
out[d] += data[t * dims + d];
}
}
for (let d = 0; d < dims; d++) out[d] /= tokens;
return out;
}
/**
* Async: returns the best-matching category key for `productName`.
* Returns null if the model is unavailable or similarity is too low.
* THRESHOLD 0.30 — below this the regex fallback is more reliable.
*/
async function classifyCategoryByEmbedding(productName) {
if (!productName) return null;
const key = productName.toLowerCase().trim();
if (_embeddingCache.has(key)) return _embeddingCache.get(key);
if (typeof window._getCategoryPipeline !== 'function') return null;
const pipe = await window._getCategoryPipeline();
if (!pipe) return null;
try {
const labels = Object.keys(_CATEGORY_DESCRIPTIONS);
const texts = [key, ...labels.map(l => _CATEGORY_DESCRIPTIONS[l])];
// Embed all texts in one batched call for efficiency
const output = await pipe(texts, { pooling: 'mean', normalize: true });
const vectors = labels.map((_, i) => {
const t = output[i + 1];
// output[i] may be a Tensor or already a plain array-like
return t.dims ? _meanPool(t) : new Float32Array(t.data ?? t);
});
const queryVec = output[0].dims
? _meanPool(output[0])
: new Float32Array(output[0].data ?? output[0]);
let bestLabel = null, bestSim = 0;
for (let i = 0; i < labels.length; i++) {
const sim = _cosineSim(queryVec, vectors[i]);
if (sim > bestSim) { bestSim = sim; bestLabel = labels[i]; }
}
const result = (bestSim >= 0.30 && bestLabel !== 'altro') ? bestLabel : null;
_embeddingCache.set(key, result);
return result;
} catch (e) {
console.warn('[EverShelf] Embedding classify error:', e);
return null;
}
}
// Determine safety level for expired products
// Returns { level: 'danger'|'warning'|'ok', icon, label, tip }
function getExpiredSafety(item, daysExpired) {
const cat = mapToLocalCategory(item.category || '', item.name || '');
const loc = (item.location || '').toLowerCase();
const inFreezer = loc === 'freezer';
const inFrigo = loc === 'frigo';
// === FREEZER: il congelamento allunga molto la vita ===
// Carne/pesce in freezer: +3 mesi. Verdura/frutta: +6 mesi. Pane: +2 mesi.
// Latticini in freezer: +1-2 mesi. Tutto il resto: +3-6 mesi.
if (inFreezer) {
const highRiskFreezer = ['carne', 'pesce'];
const medRiskFreezer = ['latticini', 'pane'];
const produceRiskFreezer = ['verdura', 'frutta'];
let bonusDays;
if (highRiskFreezer.includes(cat)) bonusDays = 90; // +3 mesi
else if (produceRiskFreezer.includes(cat)) bonusDays = 180; // +6 mesi
else if (medRiskFreezer.includes(cat)) bonusDays = 60; // +2 mesi
else bonusDays = 120; // +4 mesi default
const effectiveDays = daysExpired - bonusDays;
if (effectiveDays <= 0) {
return { level: 'ok', icon: '✅', label: t('status.ok'), tip: t('status.tip_freezer_ok').replace('{n}', bonusDays - daysExpired) };
}
if (effectiveDays <= 30) {
return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_freezer_check') };
}
return { level: 'danger', icon: '🗑️', label: t('status.discard'), tip: t('status.tip_freezer_danger') };
}
// === FRIGO e DISPENSA ===
const highRisk = ['latticini', 'carne', 'pesce', 'verdura', 'frutta'];
const medRisk = ['pane', 'surgelati'];
if (highRisk.includes(cat)) {
if (inFrigo && daysExpired <= 2) {
return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_highRisk_check') };
}
return { level: 'danger', icon: '🗑️', label: t('status.discard'), tip: t('status.tip_highRisk_danger') };
}
if (medRisk.includes(cat)) {
if (daysExpired <= 7) {
return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_medRisk_check1') };
}
if (daysExpired <= 30) {
return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_medRisk_check2') };
}
return { level: 'danger', icon: '🗑️', label: t('status.discard'), tip: t('status.tip_medRisk_danger') };
}
// LOW RISK - lunga conservazione (pasta, conserve, condimenti, cereali, snack)
if (daysExpired <= 30) {
return { level: 'ok', icon: '✅', label: t('status.ok'), tip: t('status.tip_lowRisk_ok') };
}
if (daysExpired <= 180) {
return { level: 'warning', icon: '👀', label: t('status.check'), tip: t('status.tip_lowRisk_check') };
}
return { level: 'danger', icon: '🗑️', label: t('status.discard'), tip: t('status.tip_lowRisk_danger') };
}
// Localized labels for local categories
const CATEGORY_LABELS = {
'latticini': `🥛 ${t('categories.latticini')}`, 'carne': `🥩 ${t('categories.carne')}`, 'pesce': `🐟 ${t('categories.pesce')}`,
'frutta': `🍎 ${t('categories.frutta')}`, 'verdura': `🥬 ${t('categories.verdura')}`, 'pasta': `🍝 ${t('categories.pasta')}`,
'pane': `🍞 ${t('categories.pane')}`, 'surgelati': `🧊 ${t('categories.surgelati')}`, 'bevande': `🥤 ${t('categories.bevande')}`,
'condimenti': `🧂 ${t('categories.condimenti')}`, 'snack': `🍪 ${t('categories.snack')}`, 'conserve': `🥫 ${t('categories.conserve')}`,
'cereali': `🌾 ${t('categories.cereali')}`, 'igiene': `🧴 ${t('categories.igiene')}`, 'pulizia': `🧹 ${t('categories.pulizia')}`,
'altro': `📦 ${t('categories.altro')}`
};
// Detect best unit/quantity from Open Food Facts quantity_info string
// Returns the actual package weight/volume as default (e.g. 700g → unit:'g', quantity:700)
function detectUnitAndQuantity(quantityInfo) {
if (!quantityInfo) return { unit: 'pz', quantity: 1, weightInfo: '' };
const q = quantityInfo.toLowerCase().trim();
// Match multi-pack patterns like "6 x 1l", "4 x 125g" → confezioni
const multiMatch = q.match(/(\d+)\s*x\s*([\d.,]+)\s*(ml|l|g|kg|cl)/i);
if (multiMatch) {
const count = parseInt(multiMatch[1]);
let perUnitVal = parseFloat(multiMatch[2].replace(',', '.'));
let perUnitUnit = multiMatch[3].toLowerCase();
if (perUnitUnit === 'cl') { perUnitUnit = 'ml'; perUnitVal *= 10; }
if (perUnitUnit === 'kg') { perUnitUnit = 'g'; perUnitVal *= 1000; }
if (perUnitUnit === 'l') { perUnitUnit = 'ml'; perUnitVal *= 1000; }
return { unit: 'conf', quantity: perUnitVal, packageUnit: perUnitUnit, confCount: count, weightInfo: quantityInfo };
}
// Match single package patterns like "500 g", "1 l", "750 ml", "1.5 kg"
const match = q.match(/([\d.,]+)\s*(kg|g|l|ml|cl)/i);
if (match) {
let unit = match[2].toLowerCase();
let val = parseFloat(match[1].replace(',', '.'));
if (unit === 'cl') { unit = 'ml'; val *= 10; }
if (unit === 'kg') { unit = 'g'; val *= 1000; }
if (unit === 'l') { unit = 'ml'; val *= 1000; }
return { unit, quantity: val, weightInfo: quantityInfo };
}
return { unit: 'pz', quantity: 1, weightInfo: quantityInfo };
}
// Estimate expiry days based on category/product type
const EXPIRY_DAYS = {
'latticini': 7, 'carne': 4, 'pesce': 3, 'frutta': 7, 'verdura': 7,
'pasta': 730, 'pane': 4, 'surgelati': 180, 'bevande': 365, 'condimenti': 365,
'snack': 180, 'conserve': 730, 'cereali': 365, 'igiene': 1095, 'pulizia': 1095, 'altro': 180
};
// More specific expiry by product name keywords
function estimateExpiryDays(product, location) {
const name = (product.name || '').toLowerCase();
const cat = (product.category || '').toLowerCase();
const loc = (location || '').toLowerCase();
let days;
// Specific product overrides
if (/latte\s+(fresco|intero|parzial|scremato)/.test(name)) days = 7;
else if (/latte\s+uht|latte\s+a\s+lunga/.test(name)) days = 90;
else if (/yogurt/.test(name)) days = 21;
else if (/mozzarella|burrata|stracciatella/.test(name)) days = 5;
else if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) days = 10;
else if (/parmigiano|grana|pecorino|provolone/.test(name)) days = 60;
else if (/burro/.test(name)) days = 60;
else if (/panna/.test(name)) days = 14;
else if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) days = 7;
else if (/prosciutto\s+crudo|salame|bresaola|speck/.test(name)) days = 30;
else if (/nduja/.test(name)) days = 90;
else if (/uova/.test(name)) days = 28;
else if (/pane\s+fresco|pane\s+in\s+cassetta/.test(name)) days = 5;
else if (/pane\s+confezionato|pan\s+carr|pancarrè/.test(name)) days = 14;
else if (/insalata|rucola|spinaci\s+freschi/.test(name)) days = 5;
else if (/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/.test(name)) days = 3;
else if (/salmone|tonno\s+fresco|pesce/.test(name) && !/tonno\s+in\s+scatola|tonno\s+rio/.test(name)) days = 2;
else if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) days = 1095;
else if (/surgelat|frozen|findus|4\s*salti/.test(name)) days = 180;
else if (/gelato/.test(name)) days = 365;
else if (/succo|spremuta/.test(name)) days = 7;
else if (/birra|vino/.test(name)) days = 365;
else if (/acqua/.test(name)) days = 365;
else if (/mela|mele\b/.test(name)) days = 7;
else if (/arancia|arance|mandarini|agrumi/.test(name)) days = 7;
else if (/banana|banane/.test(name)) days = 5;
else if (/pera|pere\b|fragola|fragole|uva|kiwi/.test(name)) days = 5;
else if (/carota|carote|zucchina|zucchine|peperoni|melanzane/.test(name)) days = 7;
else if (/broccoli|cavolfiore|cavolo|spinaci|bietola/.test(name)) days = 5;
else if (/cipolla|cipolle/.test(name)) days = 10;
else if (/patata|patate/.test(name)) days = 14;
else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180;
else if (/nutella|marmellata|miele/.test(name)) days = 365;
else if (/passata|pelati|pomodor/.test(name)) days = 730;
else if (/olio|aceto/.test(name)) days = 548;
else {
// Fallback to category
days = 180; // generic default
for (const [key, d] of Object.entries(EXPIRY_DAYS)) {
if (cat.includes(key)) { days = d; break; }
}
}
// Fridge extends shelf life for produce and short-lived items (sealed only)
if (loc === 'frigo') {
// Specific fridge-friendly produce overrides
if (/mela|mele/.test(name)) days = Math.max(days, 28);
else if (/arancia|arance|agrumi|mandarini|limone|limoni/.test(name)) days = Math.max(days, 21);
else if (/carota|carote/.test(name)) days = Math.max(days, 21);
else if (/cipolla/.test(name)) days = Math.max(days, 14);
else if (/patata|patate/.test(name)) days = Math.max(days, 21);
else if (/pera|pere/.test(name)) days = Math.max(days, 21);
else if (/kiwi/.test(name)) days = Math.max(days, 28);
else if (/uva/.test(name)) days = Math.max(days, 14);
else if (/fragola|fragole/.test(name)) days = Math.max(days, 7);
else if (/peperoni/.test(name)) days = Math.max(days, 14);
else if (/zucchina|zucchine/.test(name)) days = Math.max(days, 14);
else if (/melanzane/.test(name)) days = Math.max(days, 14);
else if (/broccoli|cavolfiore|cavolo/.test(name)) days = Math.max(days, 10);
// General fridge bonus: fruits and vegs that aren't already long
else if (days <= 7 && (/frutta|fruit/.test(cat) || /verdur|vegetable|plant-based/.test(cat))) {
days = Math.round(days * 2); // ~double shelf life in fridge
}
}
// Freezer extends shelf life significantly
if (loc === 'freezer' && days < 180) {
// Fresh meat/fish: 3-6 months in freezer
if (days <= 4) days = 120;
// Short-lived (cheese, dairy, bread): 2-3 months
else if (days <= 14) days = 75;
// Medium (yogurt, cured meats): 3-4 months
else if (days <= 30) days = 120;
// Already long-lasting: at least 6 months
else days = Math.max(days, 180);
}
return days;
}
function formatEstimatedExpiry(days) {
if (days <= 7) return t('expiry.days_approx').replace('{n}', days);
if (days <= 30) return t('expiry.weeks_approx').replace('{n}', Math.round(days / 7));
if (days <= 365) return t('expiry.months_approx').replace('{n}', Math.round(days / 30));
return t('expiry.years_approx').replace('{n}', Math.round(days / 365));
}
/**
* Estimate shelf life in days for an OPENED product.
* Much shorter than sealed shelf life — based on typical "once opened, consume within X days".
*/
function estimateOpenedExpiryDays(product, location) {
const name = (product.name || '').toLowerCase();
const cat = (product.category || '').toLowerCase();
const loc = (location || '').toLowerCase();
// ── A: Non-perishables — check BEFORE location ──────────────────────
if (/\bsale\b|\bsel\s+mar|\bsalt\b/.test(name) && !/\b(salmone|salame|salsa)\b/.test(name)) return 9999;
if (/\bzucchero\b|\bsugar\b/.test(name)) return 9999;
if (/\bmiele\b/.test(name)) return 9999;
if (/\baceto\b/.test(name)) return 9999;
if (/\bbicarbonato\b|\blievito\s+chimico\b/.test(name)) return 9999;
// ── B: Spirits ───────────────────────────────────────────────────────
if (/\b(sambuca|rum\b|brandy|whiskey|whisky|vodka|gin\b|grappa|amaro|aperol|campari|limoncello|cognac|porto|marsala|baileys|amaretto|vermouth)\b/.test(name)) return 730;
// ── C: Long-life regardless of location ─────────────────────────────
if (/\b(aroma|estratto|essenza|vanilli|colorante)\b/.test(name)) return 730;
if (/\b(t[eè]\b|tea\b|tisana|camomilla|verbena|infuso|rooibos)\b/.test(name)) return 730;
if (/\b(caff[eè]|coffee|nespresso)\b/.test(name)) return 365;
if (/\bolio\b/.test(name)) return 365;
if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90; // soy sauce fine opened anywhere
if (loc !== 'frigo') {
if (/\b(pasta|spaghetti|penne|rigatoni|fusilli|farfalle|tagliatelle|linguine|bucatini|lasagn|tortiglioni)\b/.test(name)) return 365;
if (/\b(riso|risotto|orzo|farro|quinoa|couscous)\b/.test(name) && !/\b(pronto|cotto)\b/.test(name)) return 365;
if (/\b(polenta|semola|maizena|amido|farina)\b/.test(name)) return 180;
}
// ── D: Freezer ───────────────────────────────────────────────────────
if (loc === 'freezer') return 90;
// ── E: Pantry fallbacks ───────────────────────────────────────────────
if (loc !== 'frigo') {
if (/\b(biscott[io]|cookies|wafer|tarall[io]|crackers?)\b/.test(name)) return 60;
if (/\b(muesli|cereali|corn\s*flakes|granola|fiocchi)\b/.test(name)) return 60;
if (/\b(confettura|marmellata)\b/.test(name)) return 90;
if (/\b(nutella|cioccolat)\b/.test(name)) return 90;
if (/\bpane\b/.test(name)) return 4;
return 60;
}
if (/latte\s+(fresco|intero|parzial|scremato)/.test(name)) return 3;
if (/latte\s+(uht|a\s+lunga)/.test(name)) return 7;
// Long-life mountain/brand milks stored in pantry before use (UHT)
if (/latte.*(montagna|alta\s+qual|parmalat|granarolo|esselunga|conservaz|microfiltrat)/i.test(name)) return 7;
if (/\blatte\b/.test(name)) return 4;
if (/\byogurt\b/.test(name)) return 5;
if (/mozzarella|burrata|stracciatella/.test(name)) return 3;
if (/philadelphia|spalmabile/.test(name)) return 7;
if (/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 5;
if (/parmigiano|grana|pecorino|provolone/.test(name)) return 21;
if (/formaggio/.test(name)) return 10;
if (/\bburro\b/.test(name)) return 30;
if (/\bpanna\b/.test(name)) return 4;
if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) return 5;
if (/prosciutto\s+crudo|salame|bresaola|speck|pancetta|nduja/.test(name)) return 7;
if (/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/.test(name)) return 2;
if (/salmone|tonno\s+fresco|pesce(?!\s+in)/.test(name)) return 2;
if (/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/.test(name)) return 5;
if (/insalata|rucola|spinaci|lattuga|crescione|germogli/.test(name)) return 2;
if (/\b(succo|spremuta)\b/.test(name)) return 3;
if (/\b(birra|beer)\b/.test(name)) return 3;
if (/\bvino\b/.test(name)) return 5;
if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) return 4;
// Fruit opened/cut in fridge
if (/\bavocado\b/.test(name)) return 2;
if (/\b(banana|banane|fragola|lampone|pesca|albicocca|ciliegia|mango|papaya)\b/.test(name)) return 2;
if (/\b(mela|pera|nettarina|prugna|kiwi|ananas|uva|melone|anguria)\b/.test(name)) return 3;
if (/\b(arancia|mandarino|pompelmo|clementina|limone)\b/.test(name)) return 3;
// Vegetables opened/cut in fridge
if (/\b(zucchina|zucchine|melanzana|pomodor)\b/.test(name)) return 3;
if (/\b(peperone|peperoni)\b/.test(name)) return 3;
if (/\b(broccolo|broccoli|cavolfiore|cavolo)\b/.test(name)) return 3;
if (/\bsedano\b|\bfinocchio\b/.test(name)) return 3;
if (/\b(cipolla|cipolle|cipollotto|scalogno|porro)\b/.test(name)) return 4;
if (/\b(carota|carote)\b/.test(name)) return 5;
if (/\b(patata|patate|tubero)\b/.test(name)) return 3;
if (/\baglio\b/.test(name)) return 10;
// ── G: Fridge condiments ─────────────────────────────────────────────
if (/maionese|mayo|mayon/.test(name)) return 90;
if (/\bketchup\b/.test(name)) return 90;
if (/\b(senape|mustard)\b/.test(name)) return 90;
if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90;
if (/\b(tabasco|worcestershire|sriracha)\b/.test(name)) return 180;
if (/confettura|marmellata/.test(name)) return 60;
if (/nutella|cioccolat/.test(name)) return 60;
// ── H: Category fallbacks ────────────────────────────────────────────
if (/dairy|latticin/.test(cat)) return 5;
if (/meat|carne/.test(cat)) return 3;
if (/fish|pesce/.test(cat)) return 2;
if (/fruit|frutta/.test(cat)) return 7;
if (/verdur|vegetable/.test(cat)) return 5;
if (/conserve/.test(cat)) return 7;
if (/condimenti|sauce/.test(cat)) return 30;
if (/bevand|beverage/.test(cat)) return 5;
return 5; // safe default for fridge
}
function addDays(days) {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().split('T')[0];
}
// Guess location from product name keywords (fallback if no category)
function guessLocationFromName(name) {
const n = (name || '').toLowerCase();
// Frigo keywords
if (/latte|yogurt|formaggio|mozzarella|burro|panna|uova|prosciutto|salame|wurstel|ricotta|mascarpone|gorgonzola|insalata|rucola|spinaci|pollo|manzo|maiale|salmone|tonno fresco|bresaola/.test(n)) return 'frigo';
// Freezer keywords
if (/surgel|frozen|gelato|ghiaccioli|bastoncini|findus|4 salti|pizza surgel|verdure surgel|minestrone surg/.test(n)) return 'freezer';
// Dispensa keywords
if (/pasta|riso|farina|zucchero|sale|olio|aceto|biscott|cracker|grissini|caffè|tè|the |tea |tonno|pelati|passata|legumi|ceci|fagioli|lenticchie|cereali|muesli|marmell|nutella|miele|cioccolat/.test(n)) return 'dispensa';
return null; // unknown
}
function guessLocation(product) {
// 1. Category-based
if (product.category) {
const cat = product.category.toLowerCase().replace(/^en:/, '').split(',')[0].trim();
// Check our map
for (const [key, loc] of Object.entries(CATEGORY_LOCATION)) {
if (cat.includes(key)) return loc;
}
// Open Food Facts categories
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin/i.test(cat)) return 'frigo';
if (/meat|viande|carne|fish|poisson|pesce/i.test(cat)) return 'frigo';
if (/frozen|surgelé|surgel/i.test(cat)) return 'freezer';
if (/fruit|vegetable|verdur|frutta/i.test(cat)) return 'frigo';
if (/beverage|drink|boisson|bevand/i.test(cat)) return 'dispensa';
if (/pasta|cereal|grain|bread|biscuit|snack|sauce|condiment|conserv|can/i.test(cat)) return 'dispensa';
}
// 2. Name-based fallback
const nameLoc = guessLocationFromName(product.name);
if (nameLoc) return nameLoc;
// 3. Default
return 'dispensa';
}
// ===== STATE =====
let currentProduct = null;
let currentInventory = [];
let _actionInventoryItems = [];
let currentLocation = '';
let scannerStream = null;
let quaggaRunning = false;
let aiStream = null;
let _scanZoomLevel = 1; // 1 or 2
async function toggleScanZoom() {
_scanZoomLevel = _scanZoomLevel === 1 ? 2 : 1;
const btn = document.getElementById('scan-zoom-btn');
if (btn) btn.textContent = `x${_scanZoomLevel}`;
if (scannerStream) {
const track = scannerStream.getVideoTracks()[0];
if (track) {
const caps = track.getCapabilities ? track.getCapabilities() : {};
if (caps.zoom) {
// Hardware zoom (Android Chrome)
const z = _scanZoomLevel === 2
? Math.min(caps.zoom.max, caps.zoom.min * 2 || 2)
: caps.zoom.min;
try { await track.applyConstraints({ advanced: [{ zoom: z }] }); } catch(e) {}
} else {
// Software zoom via CSS scale on the video element
const video = document.getElementById('scanner-video');
if (video) video.style.transform = _scanZoomLevel === 2 ? 'scale(2)' : 'scale(1)';
}
}
}
}
// ===== CAMERA HELPER =====
function getCameraConstraints(extraVideo = {}) {
const s = getSettings();
const mode = s.camera_facing || 'environment';
// Front cameras on older devices often have lower resolution — don't over-request
const isFront = (mode === 'user');
const videoConstraints = {
width: { ideal: isFront ? 640 : 1280 },
height: { ideal: isFront ? 480 : 720 },
...extraVideo
};
if (mode === 'environment' || mode === 'user') {
videoConstraints.facingMode = mode;
} else {
// Specific deviceId selected
videoConstraints.deviceId = { exact: mode };
}
return { video: videoConstraints };
}
function isFrontCamera() {
const s = getSettings();
return (s.camera_facing || 'environment') === 'user';
}
async function enumerateCameras() {
try {
// Need a temporary stream to get device labels
const tempStream = await navigator.mediaDevices.getUserMedia({ video: true });
const devices = await navigator.mediaDevices.enumerateDevices();
tempStream.getTracks().forEach(t => t.stop());
return devices.filter(d => d.kind === 'videoinput');
} catch(e) {
return [];
}
}
// ===== SETTINGS / CONFIG =====
let _settingsCache = null;
let _settingsDirty = false;
function getSettings() {
if (!_settingsCache) {
try {
_settingsCache = JSON.parse(localStorage.getItem('evershelf_settings') || '{}');
} catch(e) { _settingsCache = {}; }
}
const s = _settingsCache;
// Build recipe_prefs array from individual booleans
s.recipe_prefs = [];
if (s.pref_veloce) s.recipe_prefs.push('veloce');
if (s.pref_pocafame) s.recipe_prefs.push('pocafame');
if (s.pref_scadenze) s.recipe_prefs.push('scadenze');
if (s.pref_healthy) s.recipe_prefs.push('salutare');
if (s.pref_opened) s.recipe_prefs.push('opened');
if (s.pref_zerowaste) s.recipe_prefs.push('zerowaste');
s.dietary_restrictions = s.dietary || '';
return s;
}
function saveSettingsToStorage(settings) {
_settingsCache = settings;
localStorage.setItem('evershelf_settings', JSON.stringify(settings));
// Persist to DB
_settingsDirty = true;
_debouncedSyncSettings();
}
const _debouncedSyncSettings = debounce(function() {
if (!_settingsDirty) return;
_settingsDirty = false;
const s = getSettings();
// Don't sync secrets or device-specific settings to shared DB
const shared = {
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,
};
api('app_settings_save', {}, 'POST', { settings: { user_prefs: shared } }).catch(() => {});
}, 1000);
function debounce(fn, ms) {
let t; return function(...args) { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
async function syncSettingsFromDB() {
try {
// Primary: load from server .env
const serverSettings = await api('get_settings');
_geminiAvailable = !!(serverSettings.gemini_key_set);
_demoMode = !!serverSettings.demo_mode;
_updateGeminiButtonState();
_applyDemoModeUI();
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',
'meal_plan_enabled','tts_enabled','tts_url','tts_token',
'tts_method','tts_auth_type','tts_content_type','tts_payload_key',
'screensaver_enabled'];
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) {
if (res.settings.review_confirmed) {
_reviewConfirmedCache = res.settings.review_confirmed;
}
}
} catch(e) { /* offline, use local */ }
}
async function loadSettingsUI() {
const s = getSettings();
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;
const ssEl = document.getElementById('setting-screensaver-enabled');
if (ssEl) ssEl.checked = s.screensaver_enabled === true;
document.getElementById('setting-dietary').value = s.dietary || '';
// Camera
const cameraSelect = document.getElementById('setting-camera-facing');
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
loadCameraDevices();
renderAppliances(s.appliances || []);
const mealPlanEnabled = s.meal_plan_enabled !== false;
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
if (mpEnabledEl) mpEnabledEl.checked = mealPlanEnabled;
const mpConfigSection = document.getElementById('meal-plan-config-section');
if (mpConfigSection) mpConfigSection.style.display = mealPlanEnabled ? '' : 'none';
const mpLegendCard = document.getElementById('meal-plan-legend-card');
if (mpLegendCard) mpLegendCard.style.display = mealPlanEnabled ? '' : 'none';
renderMealPlanEditor();
// Render legend
const legend = document.querySelector('.mplan-legend');
if (legend) {
legend.innerHTML = getMealPlanTypes().map(mpt =>
`<span class="mplan-badge" style="opacity:0.85">${mpt.icon} ${mpt.label}</span>`
).join('');
}
// TTS settings — init defaults on first load
if (!s._tts_initialized) {
s.tts_url = s.tts_url || '';
s.tts_token = s.tts_token || '';
s.tts_payload_key = s.tts_payload_key || 'message';
s.tts_method = s.tts_method || 'POST';
s.tts_auth_type = s.tts_auth_type || 'bearer';
s.tts_content_type = s.tts_content_type || 'application/json';
s.tts_enabled = s.tts_enabled !== undefined ? s.tts_enabled : false;
// Default engine: 'server' if a URL was already configured, else 'browser'
if (!s.tts_engine) s.tts_engine = s.tts_url ? 'server' : 'browser';
s.tts_voice = s.tts_voice || '';
s.tts_rate = s.tts_rate !== undefined ? s.tts_rate : 1;
s.tts_pitch = s.tts_pitch !== undefined ? s.tts_pitch : 1;
s._tts_initialized = true;
saveSettingsToStorage(s);
}
const ttsEnabledEl = document.getElementById('setting-tts-enabled');
if (ttsEnabledEl) ttsEnabledEl.checked = s.tts_enabled === true;
const ttsEngineEl = document.getElementById('setting-tts-engine');
if (ttsEngineEl) { ttsEngineEl.value = s.tts_engine || 'browser'; onTtsEngineChange(ttsEngineEl.value); }
const ttsRateEl = document.getElementById('setting-tts-rate');
if (ttsRateEl) { ttsRateEl.value = s.tts_rate || 1; document.getElementById('tts-rate-label').textContent = parseFloat(s.tts_rate || 1).toFixed(1); }
const ttsPitchEl = document.getElementById('setting-tts-pitch');
if (ttsPitchEl) { ttsPitchEl.value = s.tts_pitch || 1; document.getElementById('tts-pitch-label').textContent = parseFloat(s.tts_pitch || 1).toFixed(1); }
_initBrowserTtsVoices(s.tts_voice || '');
const ttsUrlEl = document.getElementById('setting-tts-url');
if (ttsUrlEl) ttsUrlEl.value = s.tts_url || '';
const ttsMethEl = document.getElementById('setting-tts-method');
if (ttsMethEl) ttsMethEl.value = s.tts_method || 'POST';
const ttsAuthTypeEl = document.getElementById('setting-tts-auth-type');
if (ttsAuthTypeEl) { ttsAuthTypeEl.value = s.tts_auth_type || 'bearer'; onTtsAuthTypeChange(ttsAuthTypeEl.value); }
const ttsTokenEl = document.getElementById('setting-tts-token');
if (ttsTokenEl) ttsTokenEl.value = s.tts_token || '';
const ttsAuthHdrNameEl = document.getElementById('setting-tts-auth-header-name');
if (ttsAuthHdrNameEl) ttsAuthHdrNameEl.value = s.tts_auth_header_name || '';
const ttsAuthHdrValEl = document.getElementById('setting-tts-auth-header-value');
if (ttsAuthHdrValEl) ttsAuthHdrValEl.value = s.tts_auth_header_value || '';
const ttsCtEl = document.getElementById('setting-tts-content-type');
if (ttsCtEl) ttsCtEl.value = s.tts_content_type || 'application/json';
const ttsPayloadKeyEl = document.getElementById('setting-tts-payload-key');
if (ttsPayloadKeyEl) ttsPayloadKeyEl.value = s.tts_payload_key || 'message';
const ttsExtraEl = document.getElementById('setting-tts-extra-fields');
if (ttsExtraEl) ttsExtraEl.value = s.tts_extra_fields || '';
// Load server-side settings as primary source
try {
const serverSettings = await api('get_settings');
// Merge all server settings into local cache (server wins)
const serverKeys = ['bring_email',
'default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url',
'meal_plan_enabled',
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type',
'tts_content_type','tts_payload_key'];
// Note: gemini_key is never sent from server; settings_token_set is metadata only
const settingsTokenRequired = !!serverSettings.settings_token_set;
const tokenHintEl = document.getElementById('settings-token-status-hint');
if (tokenHintEl) tokenHintEl.style.display = settingsTokenRequired ? 'block' : 'none';
let changed = false;
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
changed = true;
}
}
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';
}
} catch(e) { /* offline, use local */ }
// Scale settings
const scaleEnabledUiEl = document.getElementById('setting-scale-enabled');
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
const scaleUrlUiEl = document.getElementById('setting-scale-url');
if (scaleUrlUiEl) scaleUrlUiEl.value = s.scale_gateway_url || '';
// Hide kiosk download banner if running inside Android WebView (kiosk mode)
const kioskBanner = document.getElementById('kiosk-download-banner');
if (kioskBanner && /; wv\)/.test(navigator.userAgent)) {
kioskBanner.style.display = 'none';
}
}
// ── Kiosk overlay: X (close) + ↻ (refresh) buttons ───────────────────
// Injected into #header-left (left zone of the 3-column header).
// Only shown when _kioskBridge JS interface is available (Android WebView).
function _injectKioskOverlay() {
if (typeof _kioskBridge === 'undefined') return;
if (document.getElementById('_kiosk_overlay')) return;
const headerLeft = document.getElementById('header-left');
if (!headerLeft) return;
const wrap = document.createElement('div');
wrap.id = '_kiosk_overlay';
wrap.style.cssText = 'display:flex;gap:6px;align-items:center;';
const btnStyle = 'background:rgba(255,255,255,0.2);border:none;color:#fff;width:34px;height:34px;border-radius:50%;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;';
// Exit button
const exitBtn = document.createElement('button');
exitBtn.id = '_kiosk_exit_btn';
exitBtn.textContent = '\u2715';
exitBtn.title = 'Esci dal kiosk';
exitBtn.style.cssText = btnStyle;
exitBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (confirm(t('confirm.kiosk_exit'))) _kioskBridge.exit();
});
// Refresh button
const refBtn = document.createElement('button');
refBtn.id = '_kiosk_refresh_btn';
refBtn.textContent = '\u21bb';
refBtn.title = 'Aggiorna pagina';
refBtn.style.cssText = btnStyle.replace('font-size:15px', 'font-size:18px');
refBtn.addEventListener('click', (e) => {
e.stopPropagation();
_kioskBridge.hardReload();
});
wrap.appendChild(exitBtn);
wrap.appendChild(refBtn);
headerLeft.appendChild(wrap);
}
function renderAppliances(appliances) {
const container = document.getElementById('appliances-list');
if (!appliances || appliances.length === 0) {
container.innerHTML = '<p style="color:var(--text-muted);font-size:0.85rem;padding:8px 0">Nessun elettrodomestico aggiunto</p>';
return;
}
container.innerHTML = appliances.map((a, i) => `
<div class="appliance-item">
<span>🔌 ${escapeHtml(a)}</span>
<button class="appliance-remove" onclick="removeAppliance(${i})" title="Rimuovi">✕</button>
</div>
`).join('');
}
async function loadCameraDevices() {
const select = document.getElementById('setting-camera-facing');
if (!select) return;
const s = getSettings();
const current = s.camera_facing || 'environment';
// Remove old device-specific options (keep first 2: environment, user)
while (select.options.length > 2) select.remove(2);
const cameras = await enumerateCameras();
cameras.forEach(cam => {
const opt = document.createElement('option');
opt.value = cam.deviceId;
opt.textContent = cam.label || `Camera ${cam.deviceId.slice(0, 8)}…`;
select.appendChild(opt);
});
select.value = current;
}
function addAppliance() {
const input = document.getElementById('new-appliance-input');
const name = (input.value || '').trim();
if (!name) return;
const s = getSettings();
if (!s.appliances) s.appliances = [];
if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) {
showToast(t('error.appliance_exists'), 'error');
return;
}
s.appliances.push(name);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
input.value = '';
showToast(t('toast.appliance_added'), 'success');
}
function addApplianceQuick(name) {
const s = getSettings();
if (!s.appliances) s.appliances = [];
if (s.appliances.some(a => a.toLowerCase() === name.toLowerCase())) {
showToast(t('error.already_exists'), 'error');
return;
}
s.appliances.push(name);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
showToast(`${name} aggiunto`, 'success');
}
function removeAppliance(idx) {
const s = getSettings();
if (!s.appliances) return;
s.appliances.splice(idx, 1);
saveSettingsToStorage(s);
renderAppliances(s.appliances);
}
async function saveSettings() {
const s = getSettings();
s.gemini_key = document.getElementById('setting-gemini-key').value.trim();
s.bring_email = document.getElementById('setting-bring-email').value.trim();
s.bring_password = document.getElementById('setting-bring-password').value.trim();
s.default_persons = parseInt(document.getElementById('setting-default-persons').value) || 1;
s.pref_veloce = document.getElementById('setting-pref-veloce').checked;
s.pref_pocafame = document.getElementById('setting-pref-pocafame').checked;
s.pref_scadenze = document.getElementById('setting-pref-scadenze').checked;
s.pref_healthy = document.getElementById('setting-pref-healthy').checked;
s.pref_opened = document.getElementById('setting-pref-opened').checked;
s.pref_zerowaste = document.getElementById('setting-pref-zerowaste').checked;
s.dietary = document.getElementById('setting-dietary').value.trim();
// Camera
s.camera_facing = document.getElementById('setting-camera-facing').value;
// Screensaver
const ssEl = document.getElementById('setting-screensaver-enabled');
if (ssEl) s.screensaver_enabled = ssEl.checked;
// Meal plan enabled toggle
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
if (mpEnabledEl) s.meal_plan_enabled = mpEnabledEl.checked;
// TTS settings
const ttsEnabledEl = document.getElementById('setting-tts-enabled');
if (ttsEnabledEl) s.tts_enabled = ttsEnabledEl.checked;
const ttsUrlEl2 = document.getElementById('setting-tts-url');
if (ttsUrlEl2) s.tts_url = ttsUrlEl2.value.trim();
const ttsEngineEl2 = document.getElementById('setting-tts-engine');
if (ttsEngineEl2) s.tts_engine = ttsEngineEl2.value;
const ttsVoiceEl2 = document.getElementById('setting-tts-voice');
if (ttsVoiceEl2) s.tts_voice = ttsVoiceEl2.value;
const ttsRateEl2 = document.getElementById('setting-tts-rate');
if (ttsRateEl2) s.tts_rate = parseFloat(ttsRateEl2.value) || 1;
const ttsPitchEl2 = document.getElementById('setting-tts-pitch');
if (ttsPitchEl2) s.tts_pitch = parseFloat(ttsPitchEl2.value) || 1;
const ttsMethEl2 = document.getElementById('setting-tts-method');
if (ttsMethEl2) s.tts_method = ttsMethEl2.value;
const ttsAuthTypeEl2 = document.getElementById('setting-tts-auth-type');
if (ttsAuthTypeEl2) s.tts_auth_type = ttsAuthTypeEl2.value;
const ttsTokenEl2 = document.getElementById('setting-tts-token');
if (ttsTokenEl2) s.tts_token = ttsTokenEl2.value.trim();
const ttsAuthHdrNameEl2 = document.getElementById('setting-tts-auth-header-name');
if (ttsAuthHdrNameEl2) s.tts_auth_header_name = ttsAuthHdrNameEl2.value.trim();
const ttsAuthHdrValEl2 = document.getElementById('setting-tts-auth-header-value');
if (ttsAuthHdrValEl2) s.tts_auth_header_value = ttsAuthHdrValEl2.value.trim();
const ttsCtEl2 = document.getElementById('setting-tts-content-type');
if (ttsCtEl2) s.tts_content_type = ttsCtEl2.value;
const ttsPayloadKeyEl2 = document.getElementById('setting-tts-payload-key');
if (ttsPayloadKeyEl2) s.tts_payload_key = ttsPayloadKeyEl2.value.trim() || 'message';
const ttsExtraEl2 = document.getElementById('setting-tts-extra-fields');
if (ttsExtraEl2) s.tts_extra_fields = ttsExtraEl2.value.trim();
// Scale settings
const scaleEnabledEl = document.getElementById('setting-scale-enabled');
if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked;
const scaleUrlEl = document.getElementById('setting-scale-url');
if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim();
saveSettingsToStorage(s);
// Save ALL settings to server .env
try {
const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || '';
const tokenHeader = settingsToken ? { 'X-Settings-Token': settingsToken } : {};
const result = await api('save_settings', {}, 'POST', {
...(s.gemini_key ? { gemini_key: s.gemini_key } : {}),
bring_email: s.bring_email,
...(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,
meal_plan_enabled: s.meal_plan_enabled,
screensaver_enabled: s.screensaver_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,
}, tokenHeader);
const statusEl = document.getElementById('settings-status');
if (result.success) {
statusEl.className = 'settings-status success';
statusEl.textContent = `✅ ${t('settings.saved')}`;
} else {
statusEl.className = 'settings-status error';
const errMsg = result.error === 'unauthorized'
? '🔒 Token non valido o mancante'
: `⚠️ ${t('settings.saved_local_error').replace('{error}', result.error || '')}`;
statusEl.textContent = errMsg;
}
statusEl.style.display = 'block';
setTimeout(() => statusEl.style.display = 'none', 4000);
} catch(e) {
const statusEl = document.getElementById('settings-status');
statusEl.className = 'settings-status success';
statusEl.textContent = `✅ ${t('settings.saved_local')}`;
statusEl.style.display = 'block';
setTimeout(() => statusEl.style.display = 'none', 4000);
}
}
function switchSettingsTab(btn, tabId) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
input.type = input.type === 'password' ? 'text' : 'password';
}
// ===== API HELPER =====
async function api(action, params = {}, method = 'GET', body = null, extraHeaders = {}) {
// In demo mode, all Bring! write operations are no-ops
if (_demoMode) {
const BRING_WRITE_ACTIONS = ['bring_add', 'bring_remove', 'bring_migrate_names', 'bring_set_spec'];
if (BRING_WRITE_ACTIONS.includes(action)) {
return { success: true, added: 0, removed: 0, skipped: 0, _demo: true };
}
// bring_list returns the in-memory demo list
if (action === 'bring_list') {
return { success: true, purchase: shoppingItems, listUUID: 'demo-list', _demo: true };
}
}
let url = `${API_BASE}?action=${action}`;
if (method === 'GET') {
Object.entries(params).forEach(([k, v]) => {
url += `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
});
}
const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/json', ...extraHeaders };
opts.body = JSON.stringify(body);
} else if (Object.keys(extraHeaders).length > 0) {
opts.headers = { ...extraHeaders };
}
const res = await fetch(url, opts);
if (!res.ok) {
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
if (res.status >= 500) {
reportError({
type: 'api-server-error',
message: `API ${action} returned HTTP ${res.status}`,
context: { action, status: res.status },
});
}
}
const data = await res.json();
if (data && data.error) {
remoteLog('API_FAIL', `${action}: ${data.error}`);
}
return data;
}
// ===== PAGE NAVIGATION =====
// Track current page for auto-refresh
let _currentPageId = 'dashboard';
let _currentPageParam = null;
// Refresh current page data without full navigation
function refreshCurrentPage() {
switch(_currentPageId) {
case 'dashboard': loadDashboard(); break;
case 'inventory': loadInventory(); break;
case 'shopping': loadShoppingList(); break;
case 'products': loadAllProducts(); break;
case 'recipe': loadRecipeArchive(); break;
case 'log': loadLog(); break;
// scan/ai/settings/chat: nessun dato live da ricaricare
}
}
function showPage(pageId, param = null) {
_currentPageId = pageId;
_currentPageParam = param;
// Hide all pages
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
// Show target page
const page = document.getElementById(`page-${pageId}`);
if (page) page.classList.add('active');
// Clear search inputs when navigating away
const invSearch = document.getElementById('inventory-search');
if (invSearch) invSearch.value = '';
const prodSearch = document.getElementById('products-search');
if (prodSearch) prodSearch.value = '';
// Update nav
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-btn[data-page="${pageId}"]`);
if (navBtn) navBtn.classList.add('active');
// Page-specific init
switch(pageId) {
case 'dashboard':
// Show skeleton on stat-cards while data loads
['dispensa', 'frigo', 'freezer', 'spesa'].forEach(loc => {
const el = document.getElementById(`stat-${loc}`);
if (el) { el.textContent = '…'; el.classList.add('stat-loading'); }
});
loadDashboard();
break;
case 'inventory':
if (param !== null) {
currentLocation = param;
filterLocation(param);
}
loadInventory();
break;
case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner();
// Pre-warm the embedding model the first time user visits scan page
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
window._getCategoryPipeline(); // fire-and-forget
}
break;
case 'products': loadAllProducts(); break;
case 'shopping': loadShoppingList(); break;
case 'recipe': loadRecipeArchive(); break;
case 'log': loadLog(); break;
case 'ai': initAICamera(); break;
case 'settings': loadSettingsUI(); break;
case 'chat': if (_requireGemini()) 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') {
stopScanner();
}
// Scroll to top
window.scrollTo(0, 0);
}
// ===== ANTI-WASTE SECTION =====
const WASTE_BENCHMARKS = {
it: { avgWasteRate: 22, avgKgMonth: 5.4, costPerKg: 8.2, currency: '€', countryKey: 'antiwaste.country_it', rangeMin: 8, rangeMax: 36 },
de: { avgWasteRate: 20, avgKgMonth: 6.5, costPerKg: 7.7, currency: '€', countryKey: 'antiwaste.country_de', rangeMin: 7, rangeMax: 34 },
en: { avgWasteRate: 30, avgKgMonth: 9.2, costPerKg: 8.5, currency: '$', countryKey: 'antiwaste.country_en', rangeMin: 12, rangeMax: 50 },
};
const _AW_KG_PER_EVENT = 0.5;
let _awRefreshTimer = null;
let _awFactTimer = null;
let _awBadgeTimer = null;
// ── Embedded fallback facts (used when offline / API not yet loaded) ──
const AW_FACTS_FALLBACK = {
it: [
"Nel 2024 ogni italiano spreca ~554 g di cibo a settimana (Waste Watcher 2024)",
"Lo spreco domestico in Italia vale oltre €7,5 miliardi l'anno",
"La frutta fresca è l'alimento più sprecato in Italia: ~22g/persona/settimana",
"Nel mondo si sprecano ~1,05 miliardi di tonnellate di cibo ogni anno (UNEP 2024)",
"Il 19% del cibo globale disponibile al consumo viene buttato (UNEP 2024)",
"Le famiglie sono responsabili del 60% dello spreco alimentare totale",
"Lo spreco alimentare conta per l'8-10% delle emissioni globali di gas serra",
"Se fosse un Paese, lo spreco alimentare sarebbe il 3° emettitore di CO₂ al mondo",
"Lo spreco alimentare consuma il 25% dell'acqua dolce usata in agricoltura",
"Un'area grande quanto la Cina viene coltivata per cibo mai mangiato",
"Lo spreco alimentare costa al mondo ~€1.000 miliardi l'anno",
"Il lunedì è il giorno in cui gli italiani buttano più cibo (residui del weekend)",
"Solo il 30% degli italiani sa distinguere 'da consumarsi entro' da 'preferibilmente entro'",
"Il ricorso al congelatore riduce lo spreco domestico del 20%",
"1 kg di pane sprecato = 1.300 litri d'acqua consumati inutilmente",
"Sprecare 1 hamburger = stessa acqua di una doccia da 90 minuti",
"Lo spreco alimentare pro capite in Italia è ~29 kg/anno (domestico)",
"Il 42% degli italiani dichiara di sprecare meno grazie all'aumento dei prezzi",
"Solo il 15% degli italiani chiede la 'doggy bag' al ristorante",
"Un quarto del cibo sprecato basterebbe a sfamare tutti gli affamati del mondo",
"La Legge Gadda (166/2016) è tra le norme anti-spreco più avanzate d'Europa",
"Il Sud Italia spreca in media l'8% in più rispetto al Nord",
"Nel 2024 oltre 780 milioni di persone hanno sofferto la fame nel mondo (FAO)",
"Educare i bambini a scuola riduce lo spreco familiare del 15%",
"Il packaging intelligente potrebbe ridurre lo spreco del 15%",
],
de: [
"Deutsche Haushalte werfen pro Person rund 82 kg Lebensmittel pro Jahr weg (Destatis 2024)",
"Weltweit werden ~1,05 Milliarden Tonnen Lebensmittel pro Jahr verschwendet (UNEP 2024)",
"19% des global verfügbaren Lebensmittelangebots landet im Müll (UNEP 2024)",
"Haushalte verursachen 60% der gesamten Lebensmittelverschwendung",
"Lebensmittelverschwendung ist für 8-10% der globalen Treibhausgase verantwortlich",
"Wäre Lebensmittelverschwendung ein Land, wäre es der 3. größte CO₂-Emittent",
"25% des in der Landwirtschaft genutzten Süßwassers wird für nie gegessenes Essen verbraucht",
"1 kg verschwendetes Rindfleisch ≈ 27 kg CO₂-Emissionen",
"Das Einfrieren reduziert Haushaltsabfälle um bis zu 20%",
"Nur ein Viertel der verschwendeten Lebensmittel würde alle Hungernden ernähren",
"Schlaue Verpackungen könnten den Lebensmittelabfall um 15% senken",
],
en: [
"~1.05 billion tonnes of food are wasted globally every year (UNEP 2024)",
"19% of food available for human consumption is wasted globally (UNEP 2024)",
"Households account for 60% of all food waste globally",
"Food waste represents 8-10% of global greenhouse gas emissions",
"If food waste were a country, it would be the world's 3rd largest CO₂ emitter",
"25% of freshwater used in farming grows food that is never eaten",
"Food waste costs the world ~$1 trillion per year",
"3040% of the US food supply is wasted each year (USDA 2021)",
"Americans spend ~$1,800/year on food they never eat",
"Just a quarter of wasted food would feed all the world's hungry",
"Smart packaging could cut food waste by 15%",
"1 kg of wasted bread = 1,300 litres of water wasted",
"Wasting one hamburger uses as much water as a 90-minute shower",
"Teaching children about food waste reduces household waste by 15%",
"In 2024, over 780 million people faced hunger despite global food abundance (FAO)",
],
};
// Live facts cache (loaded from API daily, falls back to embedded)
let _awLiveFacts = null;
const _AW_FACTS_LS_KEY = 'aw_facts_v2';
const _AW_FACTS_TS_KEY = 'aw_facts_ts_v2';
/** Load facts from localStorage cache or fetch from server (once per day). */
async function _awLoadFacts() {
const cached = localStorage.getItem(_AW_FACTS_LS_KEY);
const ts = parseInt(localStorage.getItem(_AW_FACTS_TS_KEY) || '0');
const age = Date.now() - ts;
// Use localStorage cache if < 24 h old
if (cached && age < 86_400_000) {
try { _awLiveFacts = JSON.parse(cached); return; } catch {}
}
// Try fetching from server if online
if (!navigator.onLine) return;
try {
const data = await api('food_facts');
if (data && data.it && data.it.length > 0) {
_awLiveFacts = data;
localStorage.setItem(_AW_FACTS_LS_KEY, JSON.stringify(data));
localStorage.setItem(_AW_FACTS_TS_KEY, String(Date.now()));
}
} catch {}
}
/** Return current facts array for the active language. */
function _awGetFacts() {
const src = _awLiveFacts || AW_FACTS_FALLBACK;
return src[_currentLang] || src['it'] || AW_FACTS_FALLBACK['it'];
}
/** Fetch fresh stats and re-render the anti-waste section. */
function _awFetchAndRender() {
if (!navigator.onLine) { _updateAwLiveDot(false); return; }
api('stats').then(s => {
_renderAntiWasteSection(
s.used_30d || 0, s.wasted_30d || 0,
s.used_prev_30d || 0, s.wasted_prev_30d || 0,
s.used_prev_60d || 0, s.wasted_prev_60d || 0,
true
);
}).catch(() => _updateAwLiveDot(false));
}
/** Update just the live indicator dot without re-rendering the whole card. */
function _updateAwLiveDot(online) {
const dot = document.querySelector('.aw-live-dot');
if (!dot) return;
dot.className = 'aw-live-dot ' + (online ? 'aw-live-on' : 'aw-live-off');
dot.title = online ? t('antiwaste.live_on') : t('antiwaste.live_off');
}
/** Start/stop the 60-second auto-refresh based on connectivity. */
function _startAntiWasteAutoRefresh() {
clearInterval(_awRefreshTimer);
if (navigator.onLine) _awRefreshTimer = setInterval(_awFetchAndRender, 60_000);
}
/**
* Start badge rotation: shows only as many badges as fit in one row (auto-measured),
* cycles through all with a fade every 5 minutes.
* Call AFTER the row is already in the DOM with the initial slice rendered.
*/
function _startBadgeRotation(allBadges, maxVisible) {
clearInterval(_awBadgeTimer);
const row = document.getElementById('aw-badges-row');
if (!row || allBadges.length <= maxVisible) return;
let start = 0;
const render = () => {
const slice = [];
for (let i = 0; i < maxVisible; i++) {
slice.push(allBadges[(start + i) % allBadges.length]);
}
row.innerHTML = slice.join('');
};
const rotate = () => {
if (!row.isConnected) { clearInterval(_awBadgeTimer); return; }
row.style.opacity = '0';
setTimeout(() => {
start = (start + 1) % allBadges.length;
render();
row.style.opacity = '1';
}, 380);
};
// Rotate every 5 minutes
_awBadgeTimer = setInterval(rotate, 5 * 60_000);
}
/** Build one trend mini-card. */
function _awTrendCard(rate, label, maxRate) {
if (rate === null) {
return `<div class="aw-tcard aw-tcard-empty">
<span class="aw-tc-label">${label}</span>
<span class="aw-tc-rate"></span>
<div class="aw-tc-minibar"><div style="width:0"></div></div>
</div>`;
}
const cls = rate <= 8 ? 'good' : rate <= 20 ? 'ok' : 'bad';
const barPct = Math.max(4, Math.round((rate / Math.max(maxRate, 5)) * 100));
return `<div class="aw-tcard aw-tcard-${cls}">
<span class="aw-tc-label">${label}</span>
<span class="aw-tc-rate">${rate}%</span>
<div class="aw-tc-minibar"><div style="width:${barPct}%"></div></div>
</div>`;
}
/** Arrow between two consecutive trend values. */
function _awTrendArrow(prev, curr) {
if (prev === null || curr === null) return null;
const d = curr - prev;
if (d <= -3) return { sym: '↓', cls: 'aw-arrow-good' };
if (d >= 3) return { sym: '↑', cls: 'aw-arrow-bad' };
return { sym: '→', cls: 'aw-arrow-ok' };
}
function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60, wastedP60, isOnline = navigator.onLine) {
const section = document.getElementById('waste-chart-section');
const total30 = used30 + wasted30;
if (total30 === 0) { section.style.display = 'none'; return; }
section.style.display = 'block';
const bm = WASTE_BENCHMARKS[_currentLang] || WASTE_BENCHMARKS['it'];
const country = t(bm.countryKey);
const myRate = Math.round((wasted30 / total30) * 100);
const avgRate = bm.avgWasteRate;
// Grade
let grade, gradeClass;
if (myRate <= 3) { grade = 'A+'; gradeClass = 'ap'; }
else if (myRate <= 8) { grade = 'A'; gradeClass = 'a'; }
else if (myRate <= 15) { grade = 'B'; gradeClass = 'b'; }
else if (myRate <= 25) { grade = 'C'; gradeClass = 'c'; }
else { grade = 'D'; gradeClass = 'd'; }
// Savings vs average
const avgWastedEvents = total30 * (avgRate / 100);
const savedEvents = Math.max(0, avgWastedEvents - wasted30);
const savedKg = +(savedEvents * _AW_KG_PER_EVENT).toFixed(1);
const savedMoney = Math.round(savedKg * bm.costPerKg);
const savedCO2 = +(savedKg * 2.5).toFixed(1);
// Status
let statusMsg, statusCls;
if (myRate < avgRate) {
statusMsg = t('antiwaste.better').replace('{country}', country).replace('{diff}', avgRate - myRate);
statusCls = 'aw-status-good';
} else if (myRate > avgRate) {
statusMsg = t('antiwaste.worse').replace('{country}', country);
statusCls = 'aw-status-bad';
} else {
statusMsg = t('antiwaste.on_par').replace('{country}', country);
statusCls = 'aw-status-ok';
}
// Single stacked bar: avg always fills 88% of track width; you fills proportionally inside
const scale = Math.max(myRate, avgRate, 1);
const avgPct = 88; // avg always = reference width
const youPct = +((myRate / scale) * 88).toFixed(1); // your bar, same scale
const youLabel = t('antiwaste.you').split(' ')[0]; // "Tu" / "You" / "Du"
// Annual totals for comparison bar
const myAnnualKg = Math.round(wasted30 * _AW_KG_PER_EVENT * 12);
const avgAnnualKg = Math.round(bm.avgKgMonth * 12);
const annualInfo = t('antiwaste.annual_info')
.replace('{you}', myAnnualKg)
.replace('{avg}', avgAnnualKg);
// Build all badge objects (shown 4 at a time, rotated every 5 min)
const diffPct = avgRate - myRate;
const allBadges = [];
allBadges.push(`<span class="aw-badge aw-badge-rate">
<span class="aw-badge-icon">📊</span>
<span class="aw-badge-body"><b>${myRate}%</b><small>${t('antiwaste.badge_rate')}</small></span>
</span>`);
if (wasted30 > 0) allBadges.push(`<span class="aw-badge aw-badge-wasted">
<span class="aw-badge-icon">🗑️</span>
<span class="aw-badge-body"><b>${wasted30}</b><small>${t('antiwaste.badge_wasted')}</small></span>
</span>`);
if (savedMoney > 0) allBadges.push(`<span class="aw-badge aw-badge-money">
<span class="aw-badge-icon">💰</span>
<span class="aw-badge-body"><b>${bm.currency}${savedMoney}/m</b><small>${t('antiwaste.badge_saved_money')}</small></span>
</span>`);
if (savedCO2 > 0) allBadges.push(`<span class="aw-badge aw-badge-co2">
<span class="aw-badge-icon">🌍</span>
<span class="aw-badge-body"><b>${savedCO2} kg</b><small>CO₂</small></span>
</span>`);
if (diffPct > 0) allBadges.push(`<span class="aw-badge aw-badge-better">
<span class="aw-badge-icon">✅</span>
<span class="aw-badge-body"><b>${diffPct}%</b><small>${t('antiwaste.badge_better')}</small></span>
</span>`);
// Initial render: show all badges (row uses nowrap so they overflow off-screen, no wrapping)
// We'll measure and trim in requestAnimationFrame below.
const initBadges = allBadges.join('');
// Facts
const facts = _awGetFacts();
const factIdx = Math.floor(Math.random() * facts.length);
const liveCls = isOnline ? 'aw-live-on' : 'aw-live-off';
const liveTip = isOnline ? t('antiwaste.live_on') : t('antiwaste.live_off');
section.innerHTML = `
<div class="aw-header">
<div class="aw-title-row">
<span class="aw-live-dot ${liveCls}" title="${liveTip}"></span>
<h3 class="aw-title">${t('antiwaste.title')}</h3>
</div>
<span class="aw-grade aw-grade-${gradeClass}" title="${t('antiwaste.grade_label')}">${grade}</span>
</div>
<div class="aw-cmp-wrap">
<div class="aw-cmp-bar-track">
<div id="aw-bar-avg" class="aw-cmp-bar-fill-avg"></div>
<div id="aw-bar-you" class="aw-cmp-bar-fill-you"></div>
</div>
<div class="aw-cmp-legend">
<span class="aw-cmp-legend-you">▮ ${youLabel} <strong>${myRate}%</strong></span>
<span class="aw-cmp-legend-avg">${country} <strong>${avgRate}%</strong> ▮</span>
</div>
<p class="aw-status-inline ${statusCls}">${statusMsg} &nbsp;·&nbsp; ${annualInfo}</p>
</div>
${allBadges.length > 0 ? `<div id="aw-badges-row" class="aw-savings-row">${initBadges}</div>` : ''}
<div class="aw-fact-rotator">
<span class="aw-fact-icon">💡</span>
<span id="aw-fact-text" class="aw-fact-text">${facts[factIdx]}</span>
</div>
<div class="aw-source">${(_awLiveFacts && _awLiveFacts.source) || t('antiwaste.source')}</div>
`;
// After DOM insertion: animate bars + measure how many badges actually fit in one row
requestAnimationFrame(() => {
// Animate comparison bars
const barYou = document.getElementById('aw-bar-you');
const barAvg = document.getElementById('aw-bar-avg');
if (barYou) { barYou.style.width = youPct + '%'; setTimeout(() => barYou.classList.add('loaded'), 100); }
if (barAvg) { barAvg.style.width = avgPct + '%'; setTimeout(() => barAvg.classList.add('loaded'), 100); }
// Measure how many badges fit in one row
const row = document.getElementById('aw-badges-row');
if (!row || !allBadges.length) return;
const GAP = 6; // matches CSS gap
const rowW = row.offsetWidth;
// Measure each badge width by reading the rendered children
const kids = [...row.children];
let totalW = 0;
let fit = 0;
for (const el of kids) {
const bw = el.offsetWidth;
if (fit > 0) totalW += GAP;
totalW += bw;
if (totalW > rowW + 1) break; // +1 for sub-pixel rounding
fit++;
}
fit = Math.max(1, fit);
// Trim visible row to the fit count
row.innerHTML = allBadges.slice(0, fit).join('');
// Start rotation only if there are more badges than fit
_startBadgeRotation(allBadges, fit);
});
// Fact rotation (every 6 s)
if (_awFactTimer) clearInterval(_awFactTimer);
if (facts.length > 1) {
let idx = factIdx;
_awFactTimer = setInterval(() => {
const el = document.getElementById('aw-fact-text');
if (!el) { clearInterval(_awFactTimer); return; }
el.classList.add('aw-fact-fade');
setTimeout(() => {
idx = (idx + 1) % facts.length;
el.textContent = facts[idx];
el.classList.remove('aw-fact-fade');
}, 420);
}, 5 * 60_000);
}
}
// ===== DASHBOARD =====
async function loadDashboard() {
try {
const [summaryData, statsData] = await Promise.all([
api('inventory_summary'),
api('stats')
]);
// Update stat cards
const summary = summaryData.summary || [];
let total = 0;
['dispensa', 'frigo', 'freezer'].forEach(loc => {
const s = summary.find(x => x.location === loc);
const count = s ? s.product_count : 0;
const el = document.getElementById(`stat-${loc}`);
el.textContent = count;
el.classList.remove('stat-loading');
total += count;
});
// Add non-standard locations
summary.forEach(s => {
if (!['dispensa', 'frigo', 'freezer'].includes(s.location)) {
total += s.product_count;
}
});
// Load shopping list count from Bring!
loadShoppingCount();
// Quick recipe button - show when there are expiring products
const recipeBar = document.getElementById('quick-recipe-bar');
if (statsData.expiring_soon && statsData.expiring_soon.length > 0) {
recipeBar.style.display = 'block';
} else {
recipeBar.style.display = 'none';
}
// Expiring items
const expiringSection = document.getElementById('alert-expiring');
const expiringList = document.getElementById('expiring-list');
if (statsData.expiring_soon && statsData.expiring_soon.length > 0) {
expiringSection.style.display = 'block';
expiringList.innerHTML = statsData.expiring_soon.map(item => {
const days = daysUntilExpiry(item.expiry_date);
let badgeText, badgeClass;
if (days === 0) { badgeText = t('expiry.today'); badgeClass = 'today'; }
else if (days === 1) { badgeText = t('expiry.tomorrow'); badgeClass = 'expiring'; }
else if (days <= 7) { badgeText = t('expiry.days').replace('{days}', days); badgeClass = 'expiring'; }
else if (days <= 30) { badgeText = t('expiry.days_compact').replace('{n}', days); badgeClass = 'expiring-soon'; }
else { const m = Math.round(days/30); badgeText = m <= 1 ? t('expiry.days_compact').replace('{n}', days) : t('expiry.months_approx').replace('{n}', m); badgeClass = 'expiring-later'; }
const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
return `
<div class="alert-item alert-item-clickable" onclick="showAlertItemDetail(${item.id}, ${item.product_id})">
<div class="alert-item-info">
<span class="alert-item-name">${escapeHtml(item.name)}</span>
${item.brand ? `<span class="alert-item-brand">${escapeHtml(item.brand)}</span>` : ''}
</div>
<div class="alert-item-badges">
<span class="alert-item-qty">📦 ${qtyDisplay}</span>
<span class="alert-item-badge ${badgeClass}">${badgeText}</span>
</div>
</div>`;
}).join('');
} else {
expiringSection.style.display = 'none';
}
// Expired items — items in the freezer that are still within the safety window are hidden
const expiredSection = document.getElementById('alert-expired');
const expiredList = document.getElementById('expired-list');
const visibleExpired = (statsData.expired || []).filter(item => {
const days = Math.abs(daysUntilExpiry(item.expiry_date));
return getExpiredSafety(item, days).level !== 'ok';
});
if (visibleExpired.length > 0) {
expiredSection.style.display = 'block';
expiredList.innerHTML = visibleExpired.map(item => {
const days = Math.abs(daysUntilExpiry(item.expiry_date));
let daysText;
if (days === 0) daysText = t('expiry.expired_today');
else if (days === 1) daysText = t('expiry.expired_yesterday');
else daysText = t('expiry.expired_days').replace('{days}', days);
const safety = getExpiredSafety(item, days);
const locIcon = item.location === 'freezer' ? '❄️' : item.location === 'frigo' ? '🧊' : '';
const qtyDisplayExp = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
return `
<div class="alert-item expired-item alert-item-clickable" onclick="showAlertItemDetail(${item.id}, ${item.product_id})">
<div class="alert-item-info">
<span class="alert-item-name">${locIcon ? locIcon + ' ' : ''}${escapeHtml(item.name)}</span>
${item.brand ? `<span class="alert-item-brand">${escapeHtml(item.brand)}</span>` : ''}
<span class="alert-item-qty">📦 ${qtyDisplayExp}</span>
</div>
<div class="alert-item-badges">
<span class="alert-item-badge expired">${daysText}</span>
<span class="safety-badge safety-${safety.level}" title="${safety.tip}">${safety.icon} ${safety.label}</span>
</div>
</div>`;
}).join('');
} else {
expiredSection.style.display = 'none';
}
// Banner alerts (suspicious quantities + consumption predictions)
loadBannerAlerts();
// Anti-waste section (load facts first so rotation has full dataset)
await _awLoadFacts();
_renderAntiWasteSection(
statsData.used_30d || 0, statsData.wasted_30d || 0,
statsData.used_prev_30d || 0, statsData.wasted_prev_30d || 0,
statsData.used_prev_60d || 0, statsData.wasted_prev_60d || 0,
navigator.onLine
);
_startAntiWasteAutoRefresh();
// Opened (partially used products with known package capacity)
const openedSection = document.getElementById('alert-opened');
const openedList = document.getElementById('opened-list');
if (statsData.opened && statsData.opened.length > 0) {
// Sorted server-side by days_to_expiry ASC
openedSection.style.display = 'block';
const MAX_SHOWN = 20;
const visible = statsData.opened.slice(0, MAX_SHOWN);
const extra = statsData.opened.length - visible.length;
openedList.innerHTML = visible.map(item => {
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const qty = parseFloat(item.quantity);
const pkgSize = parseFloat(item.default_quantity);
const unitLabels = { 'ml': 'ml', 'g': 'g', 'pz': 'pz' };
let qtyText = '';
if (item.unit === 'conf') {
const pkgUnit = item.package_unit;
const pkgLabel = unitLabels[pkgUnit] || pkgUnit;
const wholeConf = Math.floor(qty + 0.001);
const frac = Math.round((qty - wholeConf) * 1000) / 1000;
const remainderAmt = frac * pkgSize;
const remainderText = formatSubRemainder(remainderAmt, pkgUnit);
if (wholeConf > 0 && remainderAmt >= 1) {
qtyText = `${wholeConf} conf (da ${pkgSize}${pkgLabel}) + ${remainderText}`;
} else if (wholeConf > 0) {
qtyText = `${wholeConf} conf (da ${pkgSize}${pkgLabel})`;
} else {
qtyText = remainderText;
}
} else {
const unitLabel = unitLabels[item.unit] || item.unit || '';
const wholePackages = Math.floor(qty / pkgSize + 0.001);
const remainder = Math.round((qty - wholePackages * pkgSize) * 100) / 100;
if (wholePackages > 0 && remainder > 0.01) {
qtyText = `${wholePackages} × ${pkgSize}${unitLabel} + ${Math.round(remainder)}${unitLabel} ${t('inventory.qty_remainder_suffix')}`;
} else if (remainder > 0.01) {
qtyText = `${Math.round(remainder)}${unitLabel} / ${pkgSize}${unitLabel}`;
} else {
qtyText = `${qty}${unitLabel}`;
}
}
// Expiry badge
const days = item.days_to_expiry;
const isEdible = item.is_edible;
let expiryBadge = '';
if (days !== null && days !== undefined) {
let expiryClass, expiryText;
if (!isEdible) {
expiryClass = 'opened-expiry-spoiled';
expiryText = t('expiry.badge_expired');
} else if (days > 365) {
expiryClass = 'opened-expiry-ok';
expiryText = t('expiry.badge_stable');
} else if (days === 0) {
expiryClass = 'opened-expiry-today';
expiryText = t('expiry.badge_today');
} else if (days <= 2) {
expiryClass = 'opened-expiry-urgent';
expiryText = t('expiry.badge_expiring_short').replace('{n}', days);
} else if (days <= 5) {
expiryClass = 'opened-expiry-soon';
expiryText = t('expiry.badge_expiring_short').replace('{n}', days);
} else {
expiryClass = 'opened-expiry-ok';
expiryText = t('expiry.badge_ok_still').replace('{n}', days);
}
const vacuumNote = item.vacuum_sealed ? ' 🔒' : '';
expiryBadge = `<span class="alert-item-badge opened-expiry ${expiryClass}">${expiryText}${vacuumNote}</span>`;
}
return `
<div class="alert-item alert-item-clickable${!isEdible ? ' alert-item-spoiled' : ''}" onclick="showAlertItemDetail(${item.id}, ${item.product_id})">
<div class="alert-item-info">
<span class="alert-item-name">${escapeHtml(item.name)}</span>
${item.brand ? `<span class="alert-item-brand">${escapeHtml(item.brand)}</span>` : ''}
</div>
<div class="alert-item-badges">
<span class="alert-item-qty">${locInfo.icon} ${locInfo.label}</span>
<span class="alert-item-badge opened">${qtyText}</span>
${expiryBadge}
</div>
</div>`;
}).join('') + (extra > 0 ? `<div class="alert-more-note">${t('dashboard.more_opened').replace('{n}', extra)}</div>` : '');
} else {
openedSection.style.display = 'none';
}
} catch (err) {
console.error('Dashboard load error:', err);
}
}
function openedFraction(item) {
const qty = parseFloat(item.quantity);
const pkgSize = parseFloat(item.default_quantity);
if (item.unit === 'conf') {
return qty - Math.floor(qty + 0.001);
}
return (qty - Math.floor(qty / pkgSize + 0.001) * pkgSize) / pkgSize;
}
function quickRecipeSuggestion() {
if (!_requireGemini()) return;
// Navigate to chat and auto-send a prompt about expiring products
showPage('chat');
setTimeout(() => {
document.getElementById('chat-input').value = 'Suggeriscimi una ricetta veloce PER UNA PERSONA usando i prodotti che scadono prima! Ignora i prodotti in freezer (hanno scadenze molto lunghe), concentrati su frigo e dispensa.';
sendChatMessage();
}, 500);
}
// === SUSPICIOUS QUANTITY THRESHOLDS ===
const QTY_THRESHOLDS = {
'pz': { min: 0.3, max: 50 },
'conf': { min: 0.3, max: 50 },
'g': { min: 3, max: 10000 },
'ml': { min: 3, max: 10000 },
};
function isSuspiciousQty(qty, unit) {
const n = parseFloat(qty);
if (isNaN(n) || n <= 0) return false;
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;
const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit;
const th = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
return n > th.max;
}
function getReviewConfirmed() {
return _reviewConfirmedCache || {};
}
let _reviewConfirmedCache = {};
function setReviewConfirmed(inventoryId) {
const c = getReviewConfirmed();
c[inventoryId] = Date.now();
_reviewConfirmedCache = c;
api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {});
}
// === ALERT BANNER SYSTEM (replaces old review table) ===
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 + expired + expiring soon,
* 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 [invData, predData, anomalyData, finishedData] = await Promise.all([
api('inventory_list'),
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
]);
const items = invData.inventory || [];
const confirmed = getReviewConfirmed();
// 1. Expired products (highest priority) - derived from inventory
// Also considers opened_at: if item is opened and its opened-shelf-life has passed, it's expired too
items.forEach(item => {
if (!item.expiry_date && !item.opened_at) return;
if (confirmed['exp_' + item.id]) return;
let daysExpired = null;
// Check raw expiry date
if (item.expiry_date) {
const rawDays = daysUntilExpiry(item.expiry_date);
if (rawDays < 0) daysExpired = Math.abs(rawDays);
}
// Check effective expiry based on opened_at
if (item.opened_at) {
const openDays = estimateOpenedExpiryDays(item, item.location);
const openedTs = new Date(item.opened_at).getTime();
const effectiveExpiry = new Date(openedTs + openDays * 86400000);
const today = new Date(); today.setHours(0, 0, 0, 0);
const openedDiff = Math.round((effectiveExpiry.getTime() - today.getTime()) / 86400000);
if (openedDiff < 0) {
const openedExpiredDays = Math.abs(openedDiff);
if (daysExpired === null || openedExpiredDays > daysExpired) daysExpired = openedExpiredDays;
}
}
if (daysExpired === null) return; // not expired by any measure
// Skip items the freezer bonus still considers safe — no need to alarm the user
if (getExpiredSafety(item, daysExpired).level === 'ok') return;
_bannerQueue.push({ type: 'expired', data: { ...item, days_expired: daysExpired } });
});
// 2. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner)
// Group items by product identity to detect sibling entries in other locations.
// A "low quantity" alert is suppressed when other stock of the same product exists
// (e.g. 191 ml of milk in the fridge is fine if there are 11 sealed packages in the pantry).
const _productKey = item => item.barcode || `${item.name}||${item.brand || ''}`;
const _productGroups = {};
items.forEach(item => {
const k = _productKey(item);
if (!_productGroups[k]) _productGroups[k] = [];
_productGroups[k].push(item);
});
items.forEach(item => {
if (confirmed[item.id]) return;
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
const qty = parseFloat(item.quantity);
let isLow = !isNaN(qty) && qty > 0 && qty < t_.min;
let isHigh = !isNaN(qty) && qty > t_.max;
// For conf unit: evaluate thresholds on total sub-unit volume when possible,
// not on raw package count. "400 conf" with no package size is uninterpretable
// (could be grams entered with the wrong unit) — skip the high check.
if (item.unit === 'conf') {
const pkgSize = parseFloat(item.default_quantity);
if (pkgSize > 0 && item.package_unit) {
const totalSub = qty * pkgSize;
const subTh = QTY_THRESHOLDS[item.package_unit] || QTY_THRESHOLDS['pz'];
isLow = totalSub > 0 && totalSub < subTh.min;
isHigh = totalSub > subTh.max;
} else {
// No package size known — can't judge quantity; suppress high-qty noise
isHigh = false;
}
}
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
if (!isLow && !isHigh && !suspDq) return;
// Suppress low-qty warning when sibling entries for the same product exist
// in other locations — the user is simply tracking a partial/opened unit.
if (isLow && !isHigh && !suspDq) {
const siblings = (_productGroups[_productKey(item)] || []).filter(s => s.id !== item.id && parseFloat(s.quantity) > 0);
if (siblings.length > 0) return;
}
let warning;
if (suspDq && !isLow && !isHigh) warning = '📦 Conf. sospetta';
else if (isLow) warning = '⬇️ Troppo poco';
else warning = '⬆️ Troppo';
_bannerQueue.push({ type: 'review', data: { ...item, warning, _isLow: isLow } });
});
// 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 });
});
// 5. Inventory anomalies (qty doesn't match transaction history)
const anomalies = anomalyData.anomalies || [];
anomalies.forEach(an => {
if (confirmed['an_' + an.dismiss_key]) return;
_bannerQueue.push({ type: 'anomaly', data: an });
});
// 6. Finished products: inventory hit 0, waiting for user confirmation
const finished = finishedData.finished || [];
finished.forEach(fin => {
if (confirmed['fin_' + fin.product_id]) return;
_bannerQueue.push({ type: 'finished', data: fin });
});
// 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) {
console.error('[Banner] loadBannerAlerts error:', e);
}
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-799: anomalies (data discrepancies)
* 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 '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);
}
case 'anomaly': {
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
return entry.data.direction === 'missing' ? 260 : 250;
}
case 'finished':
return 600; // product ran out — confirm before removing from DB
default:
return 0;
}
}
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 === '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('expiry.expired_today_long')
: t('expiry.expired_ago_long').replace('{n}', item.days_expired);
const safety = getExpiredSafety(item, item.days_expired);
if (safety.level === 'danger') {
banner.className = 'alert-banner banner-expired banner-expired-danger';
iconEl.textContent = '🚫';
} else if (safety.level === 'warning') {
banner.className = 'alert-banner banner-expired banner-expired-warning';
iconEl.textContent = '👀';
} else {
banner.className = 'alert-banner banner-expired banner-expired-ok';
iconEl.textContent = '✅';
}
const expiredSuffix = safety.level === 'ok'
? t('expiry.expired_suffix_ok')
: safety.level === 'warning'
? t('expiry.expired_suffix_warning')
: t('expiry.expired_suffix');
titleEl.textContent = `${item.name}${item.brand ? ' (' + item.brand + ')' : ''} ${expiredSuffix}`;
const baseDetail = t('dashboard.banner_expired_detail').replace('{when}', daysText).replace('{qty}', qtyDisplay);
detailEl.innerHTML = `${baseDetail} <span class="banner-safety-tip banner-safety-${safety.level}">${safety.icon} ${safety.tip}</span>`;
let btns = '';
if (safety.level !== 'danger') {
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${safety.level === 'danger' ? ' btn-banner-throw-primary' : ''}" onclick="bannerThrowAway()">${t('dashboard.banner_expired_action_throw')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="editBannerExpiry()">${t('dashboard.banner_expired_action_edit')}</button>`;
if (safety.level === 'danger') {
btns += `<button class="btn-banner btn-banner-use btn-banner-use-danger" onclick="bannerQuickUse()">${t('dashboard.banner_expired_action_use')}</button>`;
}
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerExpired()">${t('dashboard.banner_review_dismiss')}</button>`;
actionsEl.innerHTML = btns;
} else if (entry.type === 'review') {
const item = entry.data;
// For conf unit with known package size, display the sub-unit total (e.g., 800g)
// instead of a raw conf count that could be confused with "N confezioni".
let qtyDisplay;
if (item.unit === 'conf' && parseFloat(item.default_quantity) > 0 && item.package_unit) {
const totalSub = Math.round(parseFloat(item.quantity) * parseFloat(item.default_quantity));
qtyDisplay = `${totalSub} ${item.package_unit}`;
} else {
qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
}
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
const isLow = !!item._isLow; // set when banner item was built
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
banner.className = 'alert-banner';
iconEl.textContent = '⚠️';
let titleText, detailText;
if (suspDq && !isLow) {
titleText = `${t('dashboard.banner_review_unusual_pkg_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`;
detailText = t('dashboard.banner_review_unusual_pkg_detail', { qty: item.default_quantity, unit: item.package_unit });
} else if (isLow) {
titleText = `${t('dashboard.banner_review_low_qty_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`;
detailText = t('dashboard.banner_review_low_qty_detail', { qty: qtyDisplay });
} else {
titleText = `${t('dashboard.banner_review_high_qty_title')}: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}`;
detailText = t('dashboard.banner_review_high_qty_detail', { qty: qtyDisplay });
}
titleEl.textContent = titleText;
detailEl.textContent = detailText;
let btns = `<button class="btn-banner btn-banner-ok" onclick="confirmBannerReview()">${t('dashboard.banner_review_action_ok')}</button>`;
if (isLow) {
btns += `<button class="btn-banner btn-banner-finish" onclick="bannerFinishAll()">${t('dashboard.banner_review_action_finish')}</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;
const dir = pred.direction || 'less';
const dailyRate = parseFloat(pred.daily_rate) || 0;
const daysSince = parseInt(pred.days_since_restock) || 0;
banner.className = 'alert-banner banner-prediction';
iconEl.textContent = '📊';
titleEl.textContent = `${t('dashboard.banner_prediction_title')}: ${pred.name}${pred.brand ? ' (' + pred.brand + ')' : ''}`;
let rateText = '';
if (dailyRate > 0) {
rateText = dailyRate >= 1
? t('dashboard.banner_prediction_rate_day', { n: Math.round(dailyRate), unit: pred.unit })
: t('dashboard.banner_prediction_rate_week', { n: Math.round(dailyRate * 7), unit: pred.unit });
}
const timeText = daysSince > 0 ? ` — ${t('dashboard.banner_prediction_days_ago', { n: daysSince })}` : '';
let diffText;
if (dir === 'more') {
diffText = t('dashboard.banner_prediction_more', { expected: pred.expected_qty, unit: pred.unit, time: timeText, actual: pred.actual_qty });
} else {
diffText = t('dashboard.banner_prediction_less', { expected: pred.expected_qty, unit: pred.unit, time: timeText, actual: pred.actual_qty });
}
detailEl.innerHTML = rateText ? `${rateText}: ${diffText}` : diffText.charAt(0).toUpperCase() + diffText.slice(1);
let btns = `<button class="btn-banner btn-banner-confirm" onclick="confirmBannerPrediction()">${t('dashboard.banner_prediction_action_confirm', { qty: pred.actual_qty, unit: pred.unit })}</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;
} else if (entry.type === 'finished') {
const fin = entry.data;
banner.className = 'alert-banner banner-finished';
iconEl.textContent = '📦';
const barcodeSuffix = fin.barcode && fin.barcode.length >= 3
? ` <span style="font-family:monospace;font-size:0.7em;opacity:0.6">…${escapeHtml(fin.barcode.slice(-3))}</span>`
: '';
titleEl.innerHTML = `${escapeHtml(fin.name)}${fin.brand ? ' (' + escapeHtml(fin.brand) + ')' : ''}${barcodeSuffix}${escapeHtml(t('dashboard.banner_finished_title'))}`;
const expectedText = fin.expected_qty ? ' ' + t('dashboard.banner_finished_expected', { qty: fin.expected_qty, unit: fin.unit }) : '';
detailEl.innerHTML = t('dashboard.banner_finished_zero') + expectedText + ' ' + t('dashboard.banner_finished_check');
let btns = `<button class="btn-banner btn-banner-ok" onclick="confirmBannerFinished()">${t('dashboard.banner_finished_action_yes')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="notFinishedBannerAction()">${t('dashboard.banner_finished_action_no')}</button>`;
actionsEl.innerHTML = btns;
} else if (entry.type === 'anomaly') {
const an = entry.data;
const isPhantom = an.direction === 'phantom';
const isUntracked = an.direction === 'untracked';
banner.className = 'alert-banner banner-anomaly';
iconEl.textContent = '🔍';
if (isUntracked) {
// More consumption recorded than entries — initial stock was never registered
titleEl.textContent = `${an.name}${t('dashboard.banner_anomaly_untracked_title')}`;
detailEl.innerHTML = t('dashboard.banner_anomaly_untracked_detail', { inv_qty: an.inv_qty, unit: an.unit });
} else if (isPhantom) {
titleEl.textContent = `${an.name}${t('dashboard.banner_anomaly_phantom_title')}`;
detailEl.innerHTML = t('dashboard.banner_anomaly_phantom_detail', { inv_qty: an.inv_qty, unit: an.unit, expected_qty: an.expected_qty });
} else {
titleEl.textContent = `${an.name}${t('dashboard.banner_anomaly_ghost_title')}`;
detailEl.innerHTML = t('dashboard.banner_anomaly_ghost_detail', { expected_qty: an.expected_qty, unit: an.unit, name: an.name, inv_qty: an.inv_qty });
}
let btns = `<button class="btn-banner btn-banner-edit" onclick="editBannerAnomaly()">${t('dashboard.banner_anomaly_action_edit')}</button>`;
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerAnomaly()">${t('dashboard.banner_anomaly_action_dismiss')} (${an.inv_qty} ${an.unit})</button>`;
if (_geminiAvailable) {
btns += `<button class="btn-banner btn-banner-ai" onclick="explainBannerAnomaly()" title="Chiedi a Gemini una spiegazione">\ud83e\udd16 Spiega</button>`;
}
actionsEl.innerHTML = btns;
}
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 = '';
}
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;
_bannerEditPending = true;
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('✅ Confermato — il sistema ricalcolerà le previsioni dalle prossime registrazioni', 'success');
dismissBannerItem();
}
function editBannerPrediction() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'prediction') return;
_bannerEditPending = true;
editReviewItem(entry.data.inventory_id, entry.data.product_id);
}
async function explainBannerAnomaly() {
if (!_requireGemini()) return;
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'anomaly') return;
const an = entry.data;
// Show loading inline in the banner detail area
const detailEl = document.getElementById('alert-banner-detail');
if (!detailEl) return;
const originalHtml = detailEl.innerHTML;
detailEl.innerHTML = '<em style="opacity:0.7">\ud83e\udd16 Analizzo\u2026</em>';
// Disable the Spiega button to prevent double calls
const explainBtn = document.querySelector('#alert-banner .btn-banner-ai');
if (explainBtn) explainBtn.disabled = true;
try {
const result = await api('gemini_anomaly_explain', {}, 'POST', {
name: an.name,
inv_qty: an.inv_qty,
expected_qty: an.expected_qty,
diff: an.diff,
direction: an.direction,
unit: an.unit,
lang: _currentLang,
});
if (result.success && result.explanation) {
detailEl.innerHTML = `<span style="font-size:0.85rem">\ud83e\udd16 ${escapeHtml(result.explanation)}</span>`;
} else {
detailEl.innerHTML = originalHtml;
showToast('Impossibile ottenere spiegazione AI', 'error');
}
} catch (e) {
detailEl.innerHTML = originalHtml;
showToast('Errore AI', 'error');
}
}
function editBannerAnomaly() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'anomaly') return;
_bannerEditPending = true;
editReviewItem(entry.data.inventory_id, entry.data.product_id);
}
function dismissBannerAnomaly() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'anomaly') return;
const key = entry.data.dismiss_key;
setReviewConfirmed('an_' + key);
api('dismiss_anomaly', {}, 'POST', { dismiss_key: key }).catch(() => {});
showToast('Anomalia ignorata', 'info');
dismissBannerItem();
}
function weighBannerItem() {
const entry = _bannerQueue[_bannerIndex];
if (!entry) return;
_bannerEditPending = true;
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 || [];
editInventoryItem(inventoryId);
});
}
// --- 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 bannerFinishAll() {
const entry = _bannerQueue[_bannerIndex];
if (!entry) return;
const item = entry.data;
dismissBannerItem();
api('inventory_use', {}, 'POST', {
product_id: item.product_id,
use_all: true,
location: '__all__',
}).then(res => {
if (res.success) {
showToast(`📤 ${item.name} terminato!`, 'success');
showLowStockBringPrompt(res, () => loadDashboard());
} else {
showToast(res.error || 'Errore', 'error');
}
}).catch(() => showToast(t('error.connection'), 'error'));
}
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();
}
async function confirmBannerFinished() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'finished') return;
const productId = entry.data.product_id;
try {
await api('inventory_confirm_finished', {}, 'POST', { product_id: productId });
} catch(e) {}
setReviewConfirmed('fin_' + productId);
showToast(t('toast.product_finished_confirmed'), 'success');
dismissBannerItem();
}
async function notFinishedBannerAction() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'finished') return;
const productId = entry.data.product_id;
// Remove from this session's queue (will re-appear next load if still at qty=0)
dismissBannerItem();
showLoading(true);
try {
const data = await api('product_get', { id: productId });
showLoading(false);
if (data.product) {
currentProduct = data.product;
showAddForm();
} else {
showToast(t('error.not_found'), 'error');
}
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
// --- 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 = {};
items.forEach(item => {
const localCat = mapToLocalCategory(item.category, item.name);
if (!catGroups[localCat]) catGroups[localCat] = [];
catGroups[localCat].push(item);
});
// Sort categories: use CATEGORY_ICONS key order
const catOrder = Object.keys(CATEGORY_ICONS);
const sortedCats = Object.keys(catGroups).sort((a, b) => {
const ia = catOrder.indexOf(a);
const ib = catOrder.indexOf(b);
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
});
let html = '';
for (const cat of sortedCats) {
const catItems = catGroups[cat];
const label = CATEGORY_LABELS[cat] || '📦 Altro';
html += `<div class="cat-group-header">${label} <span class="cat-group-count">${catItems.length}</span></div>`;
html += catItems.map(item => compact ? renderDashItem(item) : renderInventoryItem(item)).join('');
}
return html;
}
function renderDashItem(item) {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
const days = daysUntilExpiry(item.expiry_date);
const isExpired = days < 0;
const isExpiring = !isExpired && days <= 7;
const parts = formatQuantityParts(item.quantity, item.unit, item.default_quantity, item.package_unit);
let expiryLabel = '';
if (item.expiry_date) {
if (days < 0) expiryLabel = t('expiry.badge_expired_ago').replace('{n}', Math.abs(days));
else if (days === 0) expiryLabel = t('expiry.badge_today');
else if (days === 1) expiryLabel = t('expiry.badge_tomorrow_long');
else if (days <= 7) expiryLabel = t('expiry.badge_days').replace('{n}', days);
else expiryLabel = formatDate(item.expiry_date);
}
return `
<div class="inventory-item compact-item" onclick="dashItemTap(${item.id}, ${item.product_id})">
<div class="inv-image">
${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(item.name)}</div>
${item.brand ? `<div class="inv-brand">${escapeHtml(item.brand)}</div>` : ''}
</div>
<div class="inv-qty-right">
<span class="inv-qty-value">${parts.mainQty} <small>${parts.unitLabel}</small></span>
${parts.packageDetail ? `<span class="inv-qty-pkg-detail">${parts.packageDetail}</span>` : ''}
${expiryLabel ? `<span class="inv-expiry-small ${isExpired ? 'expired' : isExpiring ? 'expiring' : ''}">${expiryLabel}</span>` : ''}
</div>
</div>`;
}
function dashItemTap(inventoryId, productId) {
// Load full inventory so modal works
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
});
}
function showAlertItemDetail(inventoryId, productId) {
// Load full inventory so modal works (same pattern as dashItemTap)
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
});
}
function formatSubRemainder(amt, pkgUnit) {
const uL = { 'g': 'g', 'ml': 'ml' };
if (pkgUnit === 'ml' || pkgUnit === 'g') return `${Math.round(amt)}${uL[pkgUnit] || pkgUnit}`;
return `${Math.round(amt * 10) / 10}${uL[pkgUnit] || pkgUnit}`;
}
function _pzFractionLabel(n) {
const whole = Math.floor(n);
const frac = Math.round((n - whole) * 4) / 4; // nearest quarter
const fracMap = { 0.25: '¼', 0.5: '½', 0.75: '¾' };
const fracStr = fracMap[frac] || '';
if (whole === 0) return fracStr || '0';
return `${whole}${fracStr}`;
}
function formatQuantity(qty, unit, defaultQty, packageUnit) {
if (!qty && qty !== 0) return '';
const n = parseFloat(qty);
const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' };
const label = unitLabels[unit] || unit || 'pz';
// Special handling for conf with partial packages
if (unit === 'conf' && packageUnit && defaultQty > 0) {
const pkgLabel = unitLabels[packageUnit] || packageUnit;
const wholeConf = Math.floor(n + 0.001);
const fractionalConf = Math.round((n - wholeConf) * 1000) / 1000;
if (fractionalConf < 0.01) {
return `${wholeConf} conf <span class="conf-size-info">(da ${defaultQty}${pkgLabel})</span>`;
}
const remainderText = formatSubRemainder(fractionalConf * defaultQty, packageUnit);
if (wholeConf > 0) {
return `${wholeConf} conf <span class="conf-size-info">(da ${defaultQty}${pkgLabel})</span> + ${remainderText}`;
}
return remainderText;
}
let result;
if (n === Math.floor(n)) result = `${Math.floor(n)} ${label}`;
else if (unit === 'pz') result = `${_pzFractionLabel(n)} ${label}`;
else result = `${n.toFixed(1)} ${label}`;
return result;
}
// Structured quantity display for inventory cards.
// Returns { mainQty: '10', unitLabel: 'conf', packageDetail: 'da 36g', fraction: '¼' }
function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
const n = parseFloat(qty) || 0;
const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' };
const label = unitLabels[unit] || unit || 'pz';
// Special handling for conf with partial packages
if (unit === 'conf' && packageUnit && defaultQty > 0) {
const pkgLabel = unitLabels[packageUnit] || packageUnit;
const wholeConf = Math.floor(n + 0.001);
const fractionalConf = Math.round((n - wholeConf) * 1000) / 1000;
if (fractionalConf < 0.01) {
return { mainQty: `${wholeConf}`, unitLabel: 'conf', packageDetail: `da ${defaultQty}${pkgLabel}`, fraction: '' };
}
const remainderText = formatSubRemainder(fractionalConf * defaultQty, packageUnit);
if (wholeConf > 0) {
return { mainQty: `${wholeConf}`, unitLabel: 'conf', packageDetail: `da ${defaultQty}${pkgLabel}`, fraction: `+ ${remainderText}` };
}
return { mainQty: remainderText, unitLabel: '', packageDetail: '', fraction: '' };
}
let mainQty;
if (n === Math.floor(n)) mainQty = `${Math.floor(n)}`;
else if (unit === 'pz') mainQty = _pzFractionLabel(n);
else mainQty = `${n.toFixed(1)}`;
let packageDetail = '';
let fraction = '';
if (unit !== 'conf' && defaultQty && defaultQty > 1) {
const d = parseFloat(defaultQty);
const ratio = n / d;
const remainder = ratio - Math.floor(ratio);
if (remainder >= 0.1 && remainder <= 0.9) {
if (remainder < 0.38) fraction = '¼';
else if (remainder < 0.62) fraction = '½';
else fraction = '¾';
}
}
return { mainQty, unitLabel: label, packageDetail, fraction };
}
// Show package fraction: only ¼, ½, ¾ when there's a partial package.
// Returns '' if quantity maps to whole packages or fraction is not meaningful.
function formatPackageFraction(qty, defaultQty) {
if (!defaultQty || defaultQty <= 0) return '';
const n = parseFloat(qty);
const d = parseFloat(defaultQty);
if (isNaN(n) || isNaN(d) || d <= 0 || d === 1) return '';
const ratio = n / d;
const remainder = ratio - Math.floor(ratio);
// Only show if there IS a fractional part
if (remainder < 0.1 || remainder > 0.9) return '';
let frac = '';
if (remainder < 0.38) frac = '¼';
else if (remainder < 0.62) frac = '½';
else frac = '¾';
return `<span class="pkg-fraction">${frac}</span>`;
}
// ===== INVENTORY =====
async function loadInventory() {
try {
const data = await api('inventory_list', currentLocation ? { location: currentLocation } : {});
currentInventory = data.inventory || [];
renderInventory(currentInventory);
loadQuickAccess();
} catch (err) {
console.error('Inventory load error:', err);
}
}
function renderInventoryItem(item) {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const days = daysUntilExpiry(item.expiry_date);
const isExpired = days < 0;
const isExpiring = !isExpired && days <= 7;
const parts = formatQuantityParts(item.quantity, item.unit, item.default_quantity, item.package_unit);
let expiryBadge = '';
if (item.expiry_date) {
let expiryText;
if (isExpired) expiryText = t('expiry.badge_expired_ago').replace('{n}', Math.abs(days));
else if (days === 0) expiryText = t('expiry.badge_today');
else if (days === 1) expiryText = t('expiry.badge_tomorrow');
else if (days <= 7) expiryText = t('expiry.badge_days').replace('{n}', days);
else expiryText = formatDate(item.expiry_date);
expiryBadge = `<span class="inv-badge ${isExpired ? 'badge-expired' : isExpiring ? 'badge-expiry' : ''}">${expiryText}</span>`;
}
const vacuumBadge = item.vacuum_sealed ? `<span class="vacuum-badge">${t('inventory.vacuum_badge')}</span>` : '';
const openedBadge = item.opened_at ? `<span class="opened-badge">${t('inventory.opened_badge')}</span>` : '';
return `
<div class="inventory-item" onclick="showItemDetail(${item.id}, ${item.product_id})">
<div class="inv-image">
${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(item.name)}</div>
${item.brand ? `<div class="inv-brand">${escapeHtml(item.brand)}</div>` : ''}
<div class="inv-meta">
<span class="inv-badge badge-location">${locInfo.icon} ${locInfo.label}</span>
${expiryBadge}
${openedBadge}
${vacuumBadge}
</div>
</div>
<div class="inv-qty-col">
<span class="inv-qty-number">${parts.mainQty}</span>
<span class="inv-qty-unit">${parts.unitLabel}${parts.packageDetail ? ` <span class="inv-qty-pkg">${parts.packageDetail}</span>` : ''}</span>
${parts.fraction ? `<span class="inv-qty-frac">${parts.fraction}</span>` : ''}
</div>
</div>`;
}
function renderInventory(items) {
const container = document.getElementById('inventory-list');
if (items.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">📭</div><p>${t('inventory.empty_text')}</p></div>`;
return;
}
container.innerHTML = renderGroupedByCategory(items, false);
}
function filterLocation(loc) {
currentLocation = loc;
document.querySelectorAll('.location-tabs .tab').forEach(t => {
t.classList.toggle('active', t.dataset.loc === loc);
});
loadInventory();
}
function filterInventory() {
const q = document.getElementById('inventory-search').value.toLowerCase().trim();
const qas = document.getElementById('quick-access-section');
if (!q) {
if (qas) qas.style.display = '';
renderInventory(currentInventory);
return;
}
if (qas) qas.style.display = 'none';
const filtered = currentInventory.filter(i =>
i.name.toLowerCase().includes(q) ||
(i.brand && i.brand.toLowerCase().includes(q)) ||
(i.barcode && i.barcode.includes(q))
);
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 =====
function showItemDetail(inventoryId, productId) {
const item = currentInventory.find(i => i.id === inventoryId);
if (!item) return;
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${escapeHtml(item.name)}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="product-preview-small" style="margin-bottom:12px">
${item.image_url ?
`<img src="${escapeHtml(item.image_url)}" alt="" style="width:60px;height:60px;border-radius:10px;object-fit:cover">` :
`<span style="font-size:2.5rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(item.name)}</h3>
<p>${item.brand ? escapeHtml(item.brand) : ''}</p>
</div>
</div>
<div class="modal-detail">
<div class="modal-detail-row">
<span class="modal-detail-label">${t('inventory.label_position')}</span>
<span class="modal-detail-value">${locInfo.icon} ${locInfo.label}</span>
</div>
<div class="modal-detail-row">
<span class="modal-detail-label">${t('inventory.label_quantity')}</span>
<span class="modal-detail-value">${formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit)}</span>
</div>
${item.expiry_date ? `
<div class="modal-detail-row">
<span class="modal-detail-label">${t('inventory.label_expiry')}</span>
<span class="modal-detail-value">${formatDate(item.expiry_date)}</span>
</div>` : ''}
${item.vacuum_sealed ? `
<div class="modal-detail-row">
<span class="modal-detail-label">${t('inventory.label_storage')}</span>
<span class="modal-detail-value">${t('inventory.vacuum_badge')}</span>
</div>` : ''}
${item.opened_at ? `
<div class="modal-detail-row">
<span class="modal-detail-label">${t('inventory.label_status')}</span>
<span class="modal-detail-value">${t('inventory.opened_since').replace('{date}', formatDateTime(item.opened_at))}</span>
</div>` : ''}
${item.barcode ? `
<div class="modal-detail-row">
<span class="modal-detail-label">🔖 Barcode</span>
<span class="modal-detail-value">${item.barcode}</span>
</div>` : ''}
<div class="modal-detail-row">
<span class="modal-detail-label">${t('inventory.label_added')}</span>
<span class="modal-detail-value">${formatDateTime(item.added_at)}</span>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-danger flex-1" onclick="quickUse(${item.product_id}, '${item.location}')">📤 Usa</button>
<button class="btn btn-primary flex-1" onclick="editInventoryItem(${inventoryId})">✏️ Modifica</button>
<button class="btn btn-secondary" onclick="deleteInventoryItem(${inventoryId})" style="padding:12px">🗑️</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
_cancelScaleAutoConfirm(false);
_scaleRecipeAutoFillPaused = false;
_scaleUserDismissed = false;
_scaleWeightCallback = null;
_bannerEditPending = false;
}
async function quickUse(productId, location) {
closeModal();
showLoading(true);
try {
currentProduct = { id: productId };
// Get product info
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
// Extract weight_info from notes if available
if (!currentProduct.weight_info && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
}
document.getElementById('use-location').value = location;
// Mark active location button
document.querySelectorAll('#page-use .loc-btn').forEach(b => b.classList.remove('active'));
const locBtns = document.querySelectorAll('#page-use .loc-btn');
locBtns.forEach(b => {
if (b.textContent.toLowerCase().includes(location)) b.classList.add('active');
});
renderUsePreview();
// Reset scale state so the stale weight already on the scale doesn't
// immediately trigger an auto-fill. Only a weight *change* (≥10 g) after
// the page opens should be treated as a new product being placed.
_cancelScaleAutoConfirm(false); // stops timers, clears _scaleStabilityVal & _scaleLastConfirmedGrams
if (_scaleLatestWeight) {
const _baselineG = _scaleToGrams(parseFloat(_scaleLatestWeight.value), _scaleLatestWeight.unit);
if (_baselineG !== null && _baselineG >= 10) _scaleLastConfirmedGrams = _baselineG;
_scaleLatestWeight = null; // prevent immediate call inside loadUseInventoryInfo
}
loadUseInventoryInfo();
showLoading(false);
showPage('use');
} catch (err) {
showLoading(false);
console.error('quickUse error:', err);
showToast(t('error.loading'), 'error');
}
}
async function deleteInventoryItem(id) {
if (confirm(t('confirm.remove_item'))) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast(t('toast.product_removed'), 'success');
refreshCurrentPage();
}
}
function recalcEditExpiry(locInputId, vacuumInputId, expiryInputId) {
const product = window._editingProduct;
if (!product) return;
const loc = document.getElementById(locInputId)?.value || '';
const isVacuum = document.getElementById(vacuumInputId)?.checked;
// Use opened shelf life if item is already opened
let days = product._isOpened
? estimateOpenedExpiryDays(product, loc)
: estimateExpiryDays(product, loc);
if (isVacuum) days = getVacuumExpiryDays(days);
const newDate = addDays(days);
const expiryInput = document.getElementById(expiryInputId);
if (expiryInput) expiryInput.value = newDate;
}
function editInventoryItem(id) {
const item = currentInventory.find(i => i.id === id);
if (!item) {
closeModal();
showToast(t('error.not_found'), 'error');
return;
}
const isConf = (item.unit || 'pz') === 'conf';
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)
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>Modifica ${escapeHtml(item.name)}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<form class="form" onsubmit="submitEditInventory(event, ${id}, ${item.product_id})">
<div class="form-group">
<label>📦 ${t('inventory.label_quantity').replace('📦 ', '')}</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', -1)"></button>
<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>
<select id="edit-unit" class="form-input" onchange="onEditUnitChange()">
${['pz','g','ml','conf'].map(u => `<option value="${u}" ${(item.unit||'pz') === u ? 'selected' : ''}>${u === 'pz' ? 'pz (pezzi)' : u === 'g' ? 'g (grammi)' : u === 'ml' ? 'ml (millilitri)' : u === 'conf' ? 'conf (confezioni)' : u}</option>`).join('')}
</select>
</div>
<div class="form-group" id="edit-conf-size-group" style="display:${isConf ? 'block' : 'none'}">
<label>📦 Ogni confezione contiene:</label>
<div class="conf-size-inputs">
<input type="number" id="edit-conf-size" class="form-input conf-size-input" min="1" step="any" value="${confSizeVal}" placeholder="es. 300">
<select id="edit-conf-unit" class="form-input conf-size-unit">
${['g','ml'].map(u => `<option value="${u}" ${confUnitVal === u ? 'selected' : ''}>${u}</option>`).join('')}
</select>
</div>
</div>
<div class="form-group">
<label>${t('inventory.label_position')}</label>
<div class="location-selector">
${Object.entries(LOCATIONS).map(([k, v]) => `
<button type="button" class="loc-btn ${item.location === k ? 'active' : ''}"
onclick="this.parentElement.querySelectorAll('.loc-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('edit-loc').value='${k}';recalcEditExpiry('edit-loc','edit-vacuum','edit-expiry')">${v.icon} ${v.label}</button>
`).join('')}
</div>
<input type="hidden" id="edit-loc" value="${item.location}">
</div>
<div class="form-group">
<label>${t('inventory.label_expiry')}</label>
<input type="date" id="edit-expiry" value="${item.expiry_date || ''}" class="form-input">
</div>
<div class="form-group">
<label class="toggle-row">
${t('add.vacuum_label')}
<span class="toggle-switch">
<input type="checkbox" id="edit-vacuum" ${item.vacuum_sealed ? 'checked' : ''} onchange="recalcEditExpiry('edit-loc','edit-vacuum','edit-expiry')">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<button type="submit" class="btn btn-large btn-primary full-width">${t('btn.save')}</button>
</form>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
function onEditUnitChange() {
const unit = document.getElementById('edit-unit').value;
const confGroup = document.getElementById('edit-conf-size-group');
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
}
async function submitEditInventory(e, id, productId) {
e.preventDefault();
const qty = parseFloat(document.getElementById('edit-qty').value);
const loc = document.getElementById('edit-loc').value;
const expiry = document.getElementById('edit-expiry').value || null;
const unit = document.getElementById('edit-unit').value;
const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId,
vacuum_sealed: document.getElementById('edit-vacuum')?.checked ? 1 : 0 };
// Add package info if conf
if (unit === 'conf') {
payload.package_unit = document.getElementById('edit-conf-unit')?.value || '';
payload.package_size = parseFloat(document.getElementById('edit-conf-size')?.value) || 0;
} else {
// Clear package info if not conf
payload.package_unit = '';
payload.package_size = 0;
}
await api('inventory_update', {}, 'POST', payload);
closeModal();
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();
}
// ===== SCAN DEBUG LOG =====
let _scanDebugVisible = false;
let _scanLogBuffer = [];
let _scanLogTimer = null;
function scanLog(msg) {
const el = document.getElementById('scan-debug-log');
if (el) {
const _scanLocale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT';
const ts = new Date().toLocaleTimeString(_scanLocale, {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:1});
el.textContent += `[${ts}] ${msg}\n`;
el.scrollTop = el.scrollHeight;
}
console.log('[ScanDebug]', msg);
// Buffer for remote send
_scanLogBuffer.push(msg);
if (!_scanLogTimer) {
_scanLogTimer = setTimeout(flushScanLog, 2000);
}
}
function flushScanLog() {
_scanLogTimer = null;
if (_scanLogBuffer.length === 0) return;
const msgs = _scanLogBuffer.splice(0).map(m => `[SCAN] ${m}`);
_remoteLogBuffer.push(...msgs);
if (!_remoteLogTimer) {
_remoteLogTimer = setTimeout(flushRemoteLog, 2000);
}
}
function toggleScanDebug() {
const el = document.getElementById('scan-debug-log');
if (!el) return;
_scanDebugVisible = !_scanDebugVisible;
el.style.display = _scanDebugVisible ? 'block' : 'none';
}
// ===== BARCODE SCANNER =====
let _useBarcodeDetector = ('BarcodeDetector' in window);
async function initScanner() {
const video = document.getElementById('scanner-video');
const viewport = document.getElementById('scanner-viewport');
const logEl = document.getElementById('scan-debug-log');
if (logEl) logEl.textContent = '';
const constraints = getCameraConstraints();
scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`);
scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (Quagga fallback)'}`);
scanLog(`Constraints: ${JSON.stringify(constraints.video)}`);
try {
stopScanner();
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const track = stream.getVideoTracks()[0];
const caps = track.getSettings ? track.getSettings() : {};
scanLog(`Stream OK — track: ${track.label}`);
scanLog(`Resolution: ${caps.width||'?'}x${caps.height||'?'}, facing: ${caps.facingMode||'N/A'}`);
scannerStream = stream;
video.srcObject = stream;
await video.play();
scanLog(`Video playing — videoWidth: ${video.videoWidth}, videoHeight: ${video.videoHeight}`);
if (_useBarcodeDetector) {
startNativeScanner(video);
} else {
startQuaggaScanner(video);
}
} catch (err) {
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
console.error('Camera error:', err);
document.getElementById('scan-result').style.display = 'block';
document.getElementById('scan-result').innerHTML = `
<p style="color: var(--danger)">${t('error.camera')}</p>
<p style="font-size:0.85rem; color: var(--text-light); margin-top:8px">${t('scanner.camera_error_hint')}</p>
`;
}
}
// ===== NATIVE BarcodeDetector SCANNER =====
async function startNativeScanner(videoEl) {
if (quaggaRunning) return;
const scannerLine = document.querySelector('.scanner-line');
const detector = new BarcodeDetector({
formats: ['ean_13', 'ean_8', 'code_128', 'code_39', 'upc_a', 'upc_e']
});
let scanning = true;
quaggaRunning = true;
let frameCount = 0;
let partialCount = 0;
let lastDetected = '';
let detectCount = 0;
let detectionHistory = {};
scanLog('Native BarcodeDetector started');
function updateFeedback(state) {
if (!scannerLine) return;
scannerLine.classList.remove('scanning', 'detecting');
if (state) scannerLine.classList.add(state);
}
async function scanFrame() {
if (!scanning || !scannerStream) return;
frameCount++;
if (frameCount === 1) updateFeedback('scanning');
try {
const barcodes = await detector.detect(videoEl);
if (barcodes.length > 0) {
const code = barcodes[0].rawValue;
const format = barcodes[0].format;
partialCount++;
scanLog(`Native detect #${partialCount} [f${frameCount}]: ${code} (${format})`);
updateFeedback('detecting');
if (!detectionHistory[code]) detectionHistory[code] = { count: 0 };
detectionHistory[code].count++;
if (code === lastDetected) {
detectCount++;
} else {
lastDetected = code;
detectCount = 1;
}
if (detectCount >= 2 || detectionHistory[code].count >= 2) {
scanning = false;
quaggaRunning = false;
updateFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames`);
onBarcodeDetected(code);
return;
}
} else {
updateFeedback('scanning');
}
} catch (e) {
scanLog(`Native detect error: ${e.message}`);
}
if (scanning) {
if (frameCount % 30 === 0) {
scanLog(`Native scanning... f${frameCount}, partials: ${partialCount}`);
}
requestAnimationFrame(scanFrame);
}
}
requestAnimationFrame(scanFrame);
}
// ===== QUAGGA FALLBACK SCANNER =====
function startQuaggaScanner(videoEl) {
if (quaggaRunning) return;
const canvas = document.getElementById('scanner-canvas');
const ctx = canvas.getContext('2d');
const frontCam = isFrontCamera();
const scannerLine = document.querySelector('.scanner-line');
let frameCount = 0;
let partialCount = 0;
scanLog(`Quagga starting — frontCam: ${frontCam}`);
let scanning = true;
quaggaRunning = true;
let lastDetected = '';
let detectCount = 0;
let detectionHistory = {};
// Alternate between full frame and center-cropped for better detection
let scanPass = 0; // 0=full, 1=center-crop, 2=full-enhanced, 3=center-enhanced
function updateScannerFeedback(state) {
if (!scannerLine) return;
scannerLine.classList.remove('scanning', 'detecting');
if (state) scannerLine.classList.add(state);
}
function getFrameDataUrl(pass) {
const vw = videoEl.videoWidth;
const vh = videoEl.videoHeight;
if (pass % 2 === 0) {
// Full frame
canvas.width = vw;
canvas.height = vh;
ctx.drawImage(videoEl, 0, 0);
} else {
// Center crop: 60% of frame, focused on barcode area
const cropW = Math.round(vw * 0.7);
const cropH = Math.round(vh * 0.4);
const sx = Math.round((vw - cropW) / 2);
const sy = Math.round((vh - cropH) / 2);
canvas.width = cropW;
canvas.height = cropH;
ctx.drawImage(videoEl, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
}
// Apply enhancement on passes 2,3 or always for front cam
if (frontCam || pass >= 2) {
enhanceCanvasForBarcode(ctx, canvas.width, canvas.height);
}
return canvas.toDataURL('image/jpeg', 0.95);
}
function scanFrame() {
if (!scanning || !scannerStream) return;
frameCount++;
scanPass = (scanPass + 1) % 4;
const dataUrl = getFrameDataUrl(scanPass);
if (frameCount === 1) {
scanLog(`Frame #1 — video: ${videoEl.videoWidth}x${videoEl.videoHeight}`);
updateScannerFeedback('scanning');
}
let callbackCalled = false;
const safetyTimer = setTimeout(() => {
if (!callbackCalled && scanning) {
scanLog(`Quagga timeout on f${frameCount}, retrying...`);
setTimeout(scanFrame, 100);
}
}, 5000);
try {
const imgSize = Math.max(canvas.width, canvas.height);
Quagga.decodeSingle({
src: dataUrl,
numOfWorkers: 0,
inputStream: { size: Math.min(imgSize, 800) },
decoder: {
readers: [
'ean_reader',
'ean_8_reader',
'code_128_reader',
'code_39_reader',
'upc_reader',
'upc_e_reader'
],
multiple: false
},
locate: true,
locator: { patchSize: 'large', halfSample: false }
}, function(result) {
callbackCalled = true;
clearTimeout(safetyTimer);
if (result && result.codeResult) {
const code = result.codeResult.code;
const format = result.codeResult.format;
partialCount++;
const passName = ['full','crop','full+enh','crop+enh'][scanPass];
scanLog(`Partial #${partialCount} [f${frameCount} ${passName}]: ${code} (${format})`);
updateScannerFeedback('detecting');
if (!detectionHistory[code]) detectionHistory[code] = { count: 0, lastFrame: 0 };
detectionHistory[code].count++;
detectionHistory[code].lastFrame = frameCount;
if (code === lastDetected) {
detectCount++;
} else {
lastDetected = code;
detectCount = 1;
}
const dominated = detectionHistory[code];
if (detectCount >= 2 || dominated.count >= 2) {
scanning = false;
quaggaRunning = false;
updateScannerFeedback(null);
scanLog(`CONFIRMED: ${code} after ${frameCount} frames (consec:${detectCount}, total:${dominated.count})`);
onBarcodeDetected(code);
return;
}
} else {
updateScannerFeedback('scanning');
}
if (scanning) {
if (frameCount % 20 === 0) {
scanLog(`Scanning... f${frameCount}, partials: ${partialCount}, pass: ${scanPass}`);
}
setTimeout(scanFrame, 150);
}
});
} catch (e) {
callbackCalled = true;
clearTimeout(safetyTimer);
scanLog(`Quagga error: ${e.message}`);
if (scanning) setTimeout(scanFrame, 500);
}
}
setTimeout(scanFrame, 500);
}
// Enhance low-quality camera frames for better barcode recognition
function enhanceCanvasForBarcode(ctx, w, h) {
const imageData = ctx.getImageData(0, 0, w, h);
const d = imageData.data;
// Convert to high-contrast grayscale
for (let i = 0; i < d.length; i += 4) {
// Luminance
let gray = 0.299 * d[i] + 0.587 * d[i+1] + 0.114 * d[i+2];
// Increase contrast
gray = ((gray - 128) * 1.5) + 128;
gray = gray < 0 ? 0 : gray > 255 ? 255 : gray;
// Threshold to make bars more distinct
gray = gray < 140 ? 0 : 255;
d[i] = d[i+1] = d[i+2] = gray;
}
ctx.putImageData(imageData, 0, 0);
}
function stopScanner() {
quaggaRunning = false;
_scanZoomLevel = 1;
if (scannerStream) {
scannerStream.getTracks().forEach(t => t.stop());
scannerStream = null;
}
const video = document.getElementById('scanner-video');
if (video) video.srcObject = null;
const zoomBtn = document.getElementById('scan-zoom-btn');
if (zoomBtn) zoomBtn.textContent = 'x1';
// Also stop AI camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
aiStream = null;
}
const aiVideo = document.getElementById('ai-video');
if (aiVideo) aiVideo.srcObject = null;
}
async function onBarcodeDetected(barcode) {
showLoading(true);
// Vibrate if available
if (navigator.vibrate) navigator.vibrate(100);
try {
// First check local DB
const localResult = await api('search_barcode', { barcode });
if (localResult.found) {
currentProduct = localResult.product;
// If product was saved with 'pz' but has weight info in notes, fix defaults
if (currentProduct.unit === 'pz' && currentProduct.default_quantity <= 1 && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/);
if (pesoMatch) {
const weightStr = pesoMatch[1].trim();
const detected = detectUnitAndQuantity(weightStr);
if (detected.unit !== 'pz') {
currentProduct.unit = detected.unit;
currentProduct.default_quantity = detected.quantity;
currentProduct.weight_info = weightStr;
if (detected.packageUnit) currentProduct.package_unit = detected.packageUnit;
if (detected.confCount) currentProduct._confCount = detected.confCount;
// Update product in DB for future scans
api('product_save', {}, 'POST', {
id: currentProduct.id,
barcode: currentProduct.barcode,
name: currentProduct.name,
brand: currentProduct.brand || '',
category: currentProduct.category || '',
image_url: currentProduct.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
package_unit: detected.packageUnit || '',
notes: currentProduct.notes,
});
}
}
}
// Extract weight_info from notes if available (stored as "Peso: 500 g · ...")
if (!currentProduct.weight_info && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
// Detect confCount from weight_info for multipack pre-fill
if (currentProduct.weight_info && currentProduct.unit === 'conf' && !currentProduct._confCount) {
const detected = detectUnitAndQuantity(currentProduct.weight_info);
if (detected.confCount) currentProduct._confCount = detected.confCount;
}
showLoading(false);
stopScanner();
showProductAction();
return;
}
// Lookup in external DB
const lookupResult = await api('lookup_barcode', { barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
// Detect unit and quantity from quantity_info
const detected = detectUnitAndQuantity(p.quantity_info);
// Build rich notes with all available info
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
if (p.labels) notesParts.push(`Etichette: ${p.labels}`);
// Save to local DB
const saveResult = await api('product_save', {}, 'POST', {
barcode: barcode,
name: p.name || t('product.not_recognized'),
brand: p.brand || '',
category: p.category || '',
image_url: p.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
package_unit: detected.packageUnit || '',
notes: notesParts.join(' · '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: barcode,
name: p.name || t('product.not_recognized'),
brand: p.brand || '',
category: p.category || '',
image_url: p.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
package_unit: detected.packageUnit || '',
_confCount: detected.confCount || 0,
weight_info: p.quantity_info || '',
nutriscore: p.nutriscore || '',
ingredients: p.ingredients || '',
allergens: p.allergens || '',
conservation: p.conservation || '',
origin: p.origin || '',
nova_group: p.nova_group || '',
ecoscore: p.ecoscore || '',
labels: p.labels || '',
stores: p.stores || '',
};
showLoading(false);
stopScanner();
showProductAction();
return;
}
}
// Not found - ask user to add manually
showLoading(false);
stopScanner();
showToast(t('error.not_found_manual'), 'error');
startManualEntry(barcode);
} catch (err) {
showLoading(false);
console.error('Barcode lookup error:', err);
showToast(t('error.search'), 'error');
}
}
function submitManualBarcode() {
const input = document.getElementById('manual-barcode-input');
const barcode = (input.value || '').trim();
if (!barcode) {
showToast(t('error.barcode_empty'), 'error');
input.focus();
return;
}
if (!/^\d{4,14}$/.test(barcode)) {
showToast(t('error.barcode_format'), 'error');
input.focus();
return;
}
stopScanner();
onBarcodeDetected(barcode);
}
// ===== QUICK NAME ENTRY (for loose/unpackaged products) =====
async function submitQuickName() {
const input = document.getElementById('quick-product-name');
const name = (input.value || '').trim();
if (!name || name.length < 2) {
showToast(t('error.min_chars'), 'error');
input.focus();
return;
}
stopScanner();
showLoading(true);
try {
// Search local products DB
const localData = await api('products_search', { q: name });
const localProducts = (localData.products || []).slice(0, 5);
showLoading(false);
if (localProducts.length > 0) {
// Show results to pick from + option to create new
showQuickNameResults(name, localProducts);
} else {
// No local results — create new product directly
await createQuickProduct(name);
}
} catch (err) {
showLoading(false);
console.error('Quick name search error:', err);
showToast(t('error.search_short'), 'error');
}
}
function showQuickNameResults(searchName, products) {
const container = document.querySelector('.quick-name-entry');
// Remove any previous results
const oldResults = container.querySelector('.quick-name-results');
if (oldResults) oldResults.remove();
const resultsDiv = document.createElement('div');
resultsDiv.className = 'quick-name-results';
// Existing products
products.forEach(p => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(p.category, p.name)] || '📦';
const item = document.createElement('div');
item.className = 'quick-name-result-item';
item.innerHTML = `
<span class="qnr-icon">${catIcon}</span>
<div class="qnr-info">
<div class="qnr-name">${escapeHtml(p.name)}</div>
<div class="qnr-detail">${p.brand ? escapeHtml(p.brand) + ' · ' : ''}${p.barcode ? '📊 ' + p.barcode : t('product.no_barcode')}</div>
</div>
`;
item.onclick = () => selectQuickProduct(p);
resultsDiv.appendChild(item);
});
// "Create new" button
const newItem = document.createElement('div');
newItem.className = 'quick-name-result-item qnr-new';
newItem.innerHTML = `
<span class="qnr-icon"></span>
<div class="qnr-info">
<div class="qnr-name">${t('scan.create_named').replace('{name}', '"' + escapeHtml(searchName) + '"')}</div>
<div class="qnr-detail">${t('scan.new_without_barcode')}</div>
</div>
`;
newItem.onclick = () => createQuickProduct(searchName);
resultsDiv.appendChild(newItem);
container.appendChild(resultsDiv);
}
function selectQuickProduct(product) {
currentProduct = {
id: product.id,
barcode: product.barcode || '',
name: product.name,
brand: product.brand || '',
category: product.category || '',
image_url: product.image_url || '',
unit: product.unit || 'pz',
default_quantity: product.default_quantity || 1,
};
// Extract weight_info from notes if available
if (product.notes) {
const pesoMatch = product.notes.match(/Peso:\s*([^·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
clearQuickNameResults();
// Clear the search input
const qInput = document.getElementById('quick-product-name');
if (qInput) qInput.value = '';
showProductAction();
}
async function createQuickProduct(name) {
showLoading(true);
// Auto-detect category from name (sync regex first)
const category = guessCategoryFromName(name);
try {
const result = await api('product_save', {}, 'POST', {
name: name,
brand: '',
category: category,
unit: 'pz',
default_quantity: 1,
});
if (result.success || result.id) {
currentProduct = {
id: result.id,
name: name,
brand: '',
category: category,
unit: 'pz',
default_quantity: 1,
};
showLoading(false);
clearQuickNameResults();
showToast('Prodotto creato!', 'success');
// If regex gave 'altro', try embedding in background and silently update
if (category === 'altro' && typeof classifyCategoryByEmbedding === 'function') {
classifyCategoryByEmbedding(name).then(async embCat => {
if (!embCat || !result.id) return;
try {
await api('product_save', {}, 'POST', {
id: result.id,
name: name,
brand: '',
category: embCat,
unit: 'pz',
default_quantity: 1,
});
if (currentProduct && currentProduct.id === result.id) {
currentProduct.category = embCat;
}
} catch (_) { /* silent */ }
});
}
showProductAction();
} else {
showLoading(false);
showToast(result.error || t('error.save'), 'error');
}
} catch (err) {
showLoading(false);
console.error('Quick product creation error:', err);
showToast(t('error.connection'), 'error');
}
}
function clearQuickNameResults() {
const container = document.querySelector('.quick-name-entry');
if (container) {
const results = container.querySelector('.quick-name-results');
if (results) results.remove();
}
const input = document.getElementById('quick-product-name');
if (input) input.value = '';
}
function startManualEntry(barcode = '') {
stopScanner();
// Reset form
document.getElementById('pf-id').value = '';
document.getElementById('pf-name').value = '';
document.getElementById('pf-brand').value = '';
document.getElementById('pf-category').value = '';
document.getElementById('pf-unit').value = 'pz';
document.getElementById('pf-defqty').value = '1';
document.getElementById('pf-notes').value = '';
document.getElementById('pf-barcode').value = barcode || '';
document.getElementById('pf-image').value = '';
document.getElementById('pf-image-preview').style.display = 'none';
document.getElementById('product-form-title').textContent = 'Nuovo Prodotto';
const pfAiRow = document.getElementById('pf-ai-fill-row');
if (pfAiRow) pfAiRow.style.display = 'block';
// Show barcode hint when no barcode was passed
_updateBarcodeHint();
document.getElementById('pf-barcode').addEventListener('input', _updateBarcodeHint);
// Remove datalist/autocomplete suggestions for new products (they cause confusion)
document.getElementById('pf-name').removeAttribute('list');
document.getElementById('pf-brand').removeAttribute('list');
// Reset conf-size-row visibility
const pfConfRow = document.getElementById('pf-conf-size-row');
if (pfConfRow) pfConfRow.style.display = 'none';
document.getElementById('pf-conf-size').value = '';
document.getElementById('pf-conf-unit').value = 'g';
// Reset manual-edit tracking flags
document.getElementById('pf-category').dataset.manuallySet = 'false';
document.getElementById('pf-defqty').dataset.manuallySet = 'false';
// Track if user manually changes the quantity field
const qtyInput = document.getElementById('pf-defqty');
qtyInput.removeEventListener('input', markQtyManuallySet);
qtyInput.addEventListener('input', markQtyManuallySet);
// Auto-detect name → category when typing
const nameInput = document.getElementById('pf-name');
nameInput.removeEventListener('input', autoDetectCategory);
nameInput.addEventListener('input', autoDetectCategory);
showPage('product-form');
}
function markQtyManuallySet() {
document.getElementById('pf-defqty').dataset.manuallySet = 'true';
}
function autoDetectCategory() {
const name = document.getElementById('pf-name').value.toLowerCase();
if (name.length < 3) return;
const catSelect = document.getElementById('pf-category');
// Don't override if user already manually selected something
if (catSelect.dataset.manuallySet === 'true') return;
// Keywords → category mapping
const keyword2cat = {
'latte': 'latticini', 'yogurt': 'latticini', 'formaggio': 'latticini', 'mozzarella': 'latticini',
'burro': 'latticini', 'panna': 'latticini', 'ricotta': 'latticini', 'mascarpone': 'latticini',
'gorgonzola': 'latticini', 'parmigiano': 'latticini', 'grana': 'latticini', 'burrata': 'latticini',
'stracchino': 'latticini', 'uova': 'latticini',
'pollo': 'carne', 'manzo': 'carne', 'maiale': 'carne', 'vitello': 'carne', 'tacchino': 'carne',
'prosciutto': 'carne', 'salame': 'carne', 'bresaola': 'carne', 'mortadella': 'carne',
'wurstel': 'carne', 'macinato': 'carne', 'speck': 'carne',
'salmone': 'pesce', 'tonno': 'pesce', 'sgombro': 'pesce', 'pesce': 'pesce', 'merluzzo': 'pesce',
'mela': 'frutta', 'mele': 'frutta', 'banana': 'frutta', 'arancia': 'frutta', 'pera': 'frutta',
'fragola': 'frutta', 'uva': 'frutta', 'kiwi': 'frutta', 'limone': 'frutta',
'insalata': 'verdura', 'pomodor': 'verdura', 'zucchin': 'verdura', 'patat': 'verdura',
'cipoll': 'verdura', 'carota': 'verdura', 'spinaci': 'verdura', 'rucola': 'verdura',
'peperoni': 'verdura', 'melanzane': 'verdura', 'broccoli': 'verdura',
'pasta': 'pasta', 'spaghetti': 'pasta', 'penne': 'pasta', 'fusilli': 'pasta', 'riso': 'pasta',
'farina': 'pasta', 'rigatoni': 'pasta', 'farfalle': 'pasta',
'pane': 'pane', 'fette biscottate': 'pane', 'pancarrè': 'pane', 'pan carrè': 'pane',
'grissini': 'pane', 'crackers': 'pane', 'cracker': 'pane',
'surgelat': 'surgelati', 'findus': 'surgelati', 'gelato': 'surgelati',
'acqua': 'bevande', 'succo': 'bevande', 'birra': 'bevande', 'vino': 'bevande',
'coca cola': 'bevande', 'aranciata': 'bevande', 'tè': 'bevande', 'caffè': 'bevande',
'olio': 'condimenti', 'aceto': 'condimenti', 'sale': 'condimenti', 'pepe': 'condimenti',
'maionese': 'condimenti', 'ketchup': 'condimenti', 'senape': 'condimenti', 'zucchero': 'condimenti',
'biscott': 'snack', 'cioccolat': 'snack', 'nutella': 'snack', 'merendine': 'snack',
'patatine': 'snack', 'caramelle': 'snack',
'pelati': 'conserve', 'passata': 'conserve', 'legumi': 'conserve', 'ceci': 'conserve',
'fagioli': 'conserve', 'lenticchie': 'conserve', 'marmellata': 'conserve', 'miele': 'conserve',
'cereali': 'cereali', 'muesli': 'cereali', 'fiocchi': 'cereali',
};
for (const [keyword, cat] of Object.entries(keyword2cat)) {
if (name.includes(keyword)) {
catSelect.value = cat;
onCategoryChange(true);
return;
}
}
// ── Embedding fallback: async, only when keywords didn't match ──────────
// Kick off model load (no-op if already loaded/loading) and update the
// select once the result is ready. Only runs when pipeline is available.
if (typeof classifyCategoryByEmbedding === 'function') {
classifyCategoryByEmbedding(document.getElementById('pf-name').value).then(embCat => {
if (!embCat) return;
// Re-check manuallySet — user might have picked something while awaiting
const sel = document.getElementById('pf-category');
if (!sel || sel.dataset.manuallySet === 'true') return;
sel.value = embCat;
onCategoryChange(true);
});
}
}
function onCategoryChange(fromAutoDetect = false) {
const cat = document.getElementById('pf-category').value;
const unitSelect = document.getElementById('pf-unit');
const qtyInput = document.getElementById('pf-defqty');
// If user manually changed category via dropdown, don't auto-fill qty/unit
if (!fromAutoDetect) {
// Mark qty as "set" so future auto-detects won't overwrite either
qtyInput.dataset.manuallySet = 'true';
return;
}
// Auto-detect from name: suggest default unit/qty based on category
// BUT only if user hasn't manually changed the quantity field
const catDefaults = {
'latticini': { unit: 'pz', qty: 1 },
'carne': { unit: 'g', qty: 500 },
'pesce': { unit: 'g', qty: 300 },
'frutta': { unit: 'g', qty: 1000 },
'verdura': { unit: 'g', qty: 500 },
'pasta': { unit: 'g', qty: 500 },
'pane': { unit: 'pz', qty: 1 },
'surgelati': { unit: 'g', qty: 450 },
'bevande': { unit: 'ml', qty: 1000 },
'condimenti': { unit: 'pz', qty: 1 },
'snack': { unit: 'g', qty: 250 },
'conserve': { unit: 'g', qty: 400 },
'cereali': { unit: 'g', qty: 500 },
'igiene': { unit: 'pz', qty: 1 },
'pulizia': { unit: 'pz', qty: 1 },
};
if (catDefaults[cat]) {
// Only auto-fill unit/qty if user hasn't manually touched them
if (qtyInput.dataset.manuallySet !== 'true') {
unitSelect.value = catDefaults[cat].unit;
qtyInput.value = catDefaults[cat].qty;
}
}
}
function onPfUnitChange() {
const unit = document.getElementById('pf-unit').value;
const confRow = document.getElementById('pf-conf-size-row');
if (confRow) confRow.style.display = unit === 'conf' ? 'block' : 'none';
}
function _updateBarcodeHint() {
const hint = document.getElementById('pf-barcode-hint');
const val = (document.getElementById('pf-barcode')?.value || '').trim();
if (hint) hint.style.display = val ? 'none' : 'block';
}
/**
* Open a temporary camera modal to scan a barcode and fill the pf-barcode field.
* Uses BarcodeDetector if available, otherwise shows manual-input fallback.
*/
async function scanBarcodeForForm() {
const overlayEl = document.getElementById('modal-overlay');
const contentEl = document.getElementById('modal-content');
let stream = null;
let scanning = true;
const stopStream = () => {
scanning = false;
if (stream) stream.getTracks().forEach(t => t.stop());
stream = null;
};
const closeScanner = () => {
stopStream();
overlayEl.style.display = 'none';
};
contentEl.innerHTML = `
<div class="modal-header">
<h3>${t('scanner.title_barcode')}</h3>
<button class="modal-close" onclick="document.getElementById('modal-overlay').style.display='none'">✕</button>
</div>
<div style="position:relative;width:100%;background:#000;border-radius:10px;overflow:hidden;aspect-ratio:4/3">
<video id="pf-bc-video" autoplay playsinline muted style="width:100%;height:100%;object-fit:cover"></video>
<div class="scanner-line scanning" style="position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:2px;background:rgba(59,130,246,0.8)"></div>
</div>
<p style="text-align:center;margin-top:12px;color:var(--text-muted);font-size:0.88rem">${t('scanner.barcode_hint')}</p>
<div style="margin-top:10px;text-align:center">
<input type="text" id="pf-bc-manual" class="form-input" placeholder="${t('scanner.barcode_manual_placeholder')}" inputmode="numeric" style="max-width:260px;display:inline-block">
<button class="btn btn-primary" style="margin-top:8px;width:100%" onclick="
const v = document.getElementById('pf-bc-manual').value.trim();
if(v){ document.getElementById('pf-barcode').value=v; _updateBarcodeHint(); document.getElementById('modal-overlay').style.display='none'; }
">${t('scanner.barcode_use_btn')}</button>
</div>
`;
overlayEl.style.display = 'flex';
// Attach close handler (clicking backdrop)
overlayEl.onclick = (e) => { if (e.target === overlayEl) { stopStream(); overlayEl.style.display = 'none'; overlayEl.onclick = null; } };
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
const video = document.getElementById('pf-bc-video');
video.srcObject = stream;
await video.play();
if (!('BarcodeDetector' in window)) {
// No native API — just let user type manually
return;
}
const detector = new BarcodeDetector({ formats: ['ean_13','ean_8','code_128','code_39','upc_a','upc_e'] });
const detectionHistory = {};
const scanFrame = async () => {
if (!scanning || !stream) return;
try {
const barcodes = await detector.detect(video);
if (barcodes.length > 0) {
const code = barcodes[0].rawValue;
detectionHistory[code] = (detectionHistory[code] || 0) + 1;
if (detectionHistory[code] >= 2) {
scanning = false;
stopStream();
overlayEl.style.display = 'none';
overlayEl.onclick = null;
document.getElementById('pf-barcode').value = code;
_updateBarcodeHint();
if (navigator.vibrate) navigator.vibrate(80);
showToast(`🔖 Barcode acquisito: ${code}`, 'success');
return;
}
}
} catch (_) {}
if (scanning) requestAnimationFrame(scanFrame);
};
requestAnimationFrame(scanFrame);
} catch (err) {
// Camera not available — user can still type manually
const videoEl = document.getElementById('pf-bc-video');
if (videoEl) videoEl.style.display = 'none';
}
}
async function submitProduct(e) {
e.preventDefault();
showLoading(true);
const pfUnit = document.getElementById('pf-unit').value;
const productData = {
id: document.getElementById('pf-id').value || null,
name: document.getElementById('pf-name').value,
brand: document.getElementById('pf-brand').value,
category: document.getElementById('pf-category').value,
unit: pfUnit,
default_quantity: pfUnit === 'conf' ? (parseFloat(document.getElementById('pf-conf-size')?.value) || 1) : (parseFloat(document.getElementById('pf-defqty').value) || 1),
package_unit: pfUnit === 'conf' ? (document.getElementById('pf-conf-unit')?.value || '') : '',
notes: document.getElementById('pf-notes').value,
barcode: document.getElementById('pf-barcode').value || null,
image_url: document.getElementById('pf-image').value || '',
};
try {
const result = await api('product_save', {}, 'POST', productData);
if (result.success) {
currentProduct = { ...productData, id: result.id };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || t('error.save'), 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
// ===== PRODUCT ACTION (IN/OUT) =====
function showProductAction() {
if (!currentProduct) return;
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || '📦';
const nutriscoreColors = { a: '#1e8f4e', b: '#60ac0e', c: '#eeae0e', d: '#ff6f1e', e: '#e63e11' };
let detailsHtml = '';
// Weight / quantity info
if (currentProduct.weight_info) {
detailsHtml += `<div class="product-detail-tag">⚖️ ${escapeHtml(currentProduct.weight_info)}</div>`;
}
// Nutriscore badge
if (currentProduct.nutriscore) {
const ns = currentProduct.nutriscore.toLowerCase();
const nsColor = nutriscoreColors[ns] || '#999';
detailsHtml += `<div class="product-detail-tag" style="background:${nsColor};color:#fff;font-weight:600">Nutri-Score ${ns.toUpperCase()}</div>`;
}
// NOVA group
if (currentProduct.nova_group) {
const novaLabels = { '1': t('nova.1'), '2': t('nova.2'), '3': t('nova.3'), '4': t('nova.4') };
detailsHtml += `<div class="product-detail-tag">🏭 NOVA ${currentProduct.nova_group}${novaLabels[currentProduct.nova_group] ? ' - ' + novaLabels[currentProduct.nova_group] : ''}</div>`;
}
// Ecoscore
if (currentProduct.ecoscore) {
const es = currentProduct.ecoscore.toLowerCase();
const esColor = nutriscoreColors[es] || '#999';
detailsHtml += `<div class="product-detail-tag" style="background:${esColor};color:#fff;font-weight:600">🌍 Eco-Score ${es.toUpperCase()}</div>`;
}
// Origin
if (currentProduct.origin) {
detailsHtml += `<div class="product-detail-tag">📍 ${escapeHtml(currentProduct.origin)}</div>`;
}
// Labels (bio, DOP, etc.)
if (currentProduct.labels) {
detailsHtml += `<div class="product-detail-tag">🏷️ ${escapeHtml(currentProduct.labels)}</div>`;
}
// Allergens
let allergensHtml = '';
if (currentProduct.allergens) {
allergensHtml = `<div class="product-allergens">⚠️ <strong>Allergeni:</strong> ${escapeHtml(currentProduct.allergens)}</div>`;
}
// Ingredients (collapsible)
let ingredientsHtml = '';
if (currentProduct.ingredients) {
ingredientsHtml = `
<details class="product-ingredients">
<summary>📋 Ingredienti</summary>
<p>${escapeHtml(currentProduct.ingredients)}</p>
</details>
`;
}
// Conservation
let conservationHtml = '';
if (currentProduct.conservation) {
conservationHtml = `<div class="product-conservation">🧊 ${escapeHtml(currentProduct.conservation)}</div>`;
}
// LARGER product preview
document.getElementById('action-product-preview').innerHTML = `
${currentProduct.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span class="product-preview-emoji">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct.name)}</h3>
<p>${currentProduct.brand ? `<strong>${escapeHtml(currentProduct.brand)}</strong>` : ''}</p>
${currentProduct.weight_info ? `<p style="font-size:0.85rem;color:var(--text-light)">⚖️ ${escapeHtml(currentProduct.weight_info)}</p>` : ''}
${currentProduct.barcode ? `<p style="font-size:0.75rem;color:var(--text-muted)">📊 ${currentProduct.barcode}</p>` : ''}
</div>
<button type="button" class="btn-edit-inline" onclick="toggleActionEdit()" title="Modifica nome/marca">✏️</button>
`;
// Check if product needs editing (unknown name, missing info)
const isUnknown = !currentProduct.name ||
/sconosciuto|unknown|^$/i.test(currentProduct.name.trim()) ||
currentProduct.name.trim().length < 2;
// Edit product info section
let editInfoEl = document.getElementById('action-edit-info');
if (!editInfoEl) {
editInfoEl = document.createElement('div');
editInfoEl.id = 'action-edit-info';
const preview = document.getElementById('action-product-preview');
preview.parentElement.insertBefore(editInfoEl, preview.nextSibling);
}
// Always build the edit form, but only show it auto-opened for unknown products
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([key, label]) =>
`<option value="${key}" ${mapToLocalCategory(currentProduct.category, currentProduct.name) === key ? 'selected' : ''}>${label}</option>`
).join('');
editInfoEl.innerHTML = `
<div class="edit-unknown-card ${isUnknown ? 'highlight' : ''}">
<h4>${isUnknown ? '⚠️ Prodotto non riconosciuto' : '✏️ Modifica informazioni'}</h4>
${isUnknown ? '<p class="edit-unknown-hint">Inserisci il nome e le informazioni del prodotto</p>' : ''}
<div class="edit-unknown-form">
<div class="form-group">
<label>${t('edit.label_name')}</label>
<input type="text" id="edit-action-name" class="form-input" value="${escapeHtml(isUnknown ? '' : currentProduct.name)}" placeholder="Es: Latte intero, Pasta penne..." required>
</div>
<div class="form-group">
<label>🏪 Marca</label>
<input type="text" id="edit-action-brand" class="form-input" value="${escapeHtml(currentProduct.brand || '')}" placeholder="Es: Barilla, Mulino Bianco...">
</div>
<div class="form-group">
<label>📂 Categoria</label>
<select id="edit-action-category" class="form-input">
<option value="">-- Seleziona --</option>
${categoryOptions}
</select>
</div>
<button type="button" class="btn btn-primary full-width" onclick="saveEditedProductInfo()">${t('btn.save_info')}</button>
</div>
</div>
`;
editInfoEl.style.display = isUnknown ? 'block' : 'none';
if (isUnknown) {
setTimeout(() => document.getElementById('edit-action-name')?.focus(), 100);
}
// Show extra product info section below preview
let extraInfoEl = document.getElementById('action-product-details');
if (!extraInfoEl) {
const container = document.getElementById('action-product-preview').parentElement;
extraInfoEl = document.createElement('div');
extraInfoEl.id = 'action-product-details';
const actionBtns = document.getElementById('action-buttons-container');
actionBtns.parentElement.insertBefore(extraInfoEl, actionBtns);
}
if (detailsHtml || allergensHtml || ingredientsHtml || conservationHtml) {
extraInfoEl.innerHTML = `
<div class="product-details-card">
${detailsHtml ? `<div class="product-detail-tags">${detailsHtml}</div>` : ''}
${allergensHtml}
${ingredientsHtml}
${conservationHtml}
</div>
`;
extraInfoEl.style.display = 'block';
} else {
extraInfoEl.style.display = 'none';
extraInfoEl.innerHTML = '';
}
// === CHECK INVENTORY FOR THIS PRODUCT ===
checkInventoryForProduct(currentProduct.id).then(inventoryItems => {
_actionInventoryItems = inventoryItems;
const statusBar = document.getElementById('action-inventory-status');
const btnsContainer = document.getElementById('action-buttons-container');
if (inventoryItems.length > 0) {
// Product IS in inventory - show status and 3 buttons
statusBar.style.display = 'block';
let totalQty = 0;
const unit = inventoryItems[0].unit || 'pz';
const defQty = inventoryItems[0].default_quantity || 0;
const pkgUnit = inventoryItems[0].package_unit || '';
const invHtml = inventoryItems.map(inv => {
const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location };
const qtyStr = formatQuantity(inv.quantity, inv.unit, inv.default_quantity, inv.package_unit);
const pkgF = formatPackageFraction(inv.quantity, inv.default_quantity);
totalQty += parseFloat(inv.quantity);
let expiryStr = '';
if (inv.expiry_date) {
const d = daysUntilExpiry(inv.expiry_date);
if (d < 0) expiryStr = ` · ${t('expiry.badge_expired_ago').replace('{n}', Math.abs(d))}`;
else if (d <= 3) expiryStr = ` · ${t('expiry.badge_expires_red').replace('{n}', d)}`;
else if (d <= 7) expiryStr = ` · ${t('expiry.badge_expires_yellow').replace('{n}', d)}`;
else expiryStr = ` · 📅 ${formatDate(inv.expiry_date)}`;
}
const vacuumIcon = inv.vacuum_sealed ? ' 🫙' : '';
return `<div class="inv-status-item inv-status-item-clickable" onclick="editActionInventoryItem(${inv.id})"><span>${locInfo.icon} ${locInfo.label}${vacuumIcon}${expiryStr}</span><span class="inv-status-qty">${qtyStr}${pkgF ? ' ' + pkgF : ''} ✏️</span></div>`;
}).join('');
const totalStr = formatQuantity(totalQty, unit, defQty, pkgUnit);
const totalFrac = formatPackageFraction(totalQty, defQty);
statusBar.innerHTML = `
<div class="inv-status-header">
<span class="inv-status-title">${t('action.have_title')}</span>
<div class="inv-status-total-col">
<span class="inv-status-total">${totalStr}</span>
${totalFrac ? `<span class="inv-status-total-frac">${totalFrac}</span>` : ''}
</div>
</div>
<div class="inv-status-items">${invHtml}</div>
`;
btnsContainer.className = 'action-buttons-4col';
btnsContainer.innerHTML = `
<button class="btn btn-huge btn-success" onclick="showAddForm()">
<span class="btn-icon">📥</span>
<span class="btn-text">${t('action.add_btn')}<br><small>${t('action.add_more_sub')}</small></span>
</button>
<button class="btn btn-huge btn-danger" onclick="showUseForm()">
<span class="btn-icon">📤</span>
<span class="btn-text">${t('action.use_btn')}<br><small>${t('action.use_qty_sub')}</small></span>
</button>
<button class="btn btn-huge btn-throw" onclick="showThrowForm()">
<span class="btn-icon">🗑️</span>
<span class="btn-text">${t('action.throw_btn')}<br><small>${t('action.throw_sub')}</small></span>
</button>
<button class="btn btn-huge btn-edit" onclick="openInventoryEdit()">
<span class="btn-icon">✏️</span>
<span class="btn-text">${t('product.modify_details')}<br><small>${t('action.edit_sub')}</small></span>
</button>
`;
// Secondary: catalog edit link below the buttons (one instance only)
let catalogLink = document.getElementById('catalog-edit-link');
if (!catalogLink) {
catalogLink = document.createElement('div');
catalogLink.id = 'catalog-edit-link';
catalogLink.style.cssText = 'text-align:center;margin-top:6px';
btnsContainer.after(catalogLink);
}
catalogLink.innerHTML = `<button type="button" class="btn-link-small" onclick="editProductFromAction()">⚙️ Modifica scheda prodotto (nome, marca, categoria…)</button>`;
} else {
// Product NOT in inventory - show only AGGIUNGI
statusBar.style.display = 'none';
btnsContainer.className = 'action-buttons';
btnsContainer.innerHTML = `
<button class="btn btn-huge btn-success" onclick="showAddForm()" style="flex:1">
<span class="btn-icon">📥</span>
<span class="btn-text">${t('action.add_btn')}<br><small>${t('action.add_sub')}</small></span>
</button>
`;
// Remove catalog-edit link if left over from a previous product
const orphan = document.getElementById('catalog-edit-link');
if (orphan) orphan.remove();
}
});
// Update back button: go back to shopping if came from shopping list scan
const backBtn = document.getElementById('action-back-btn');
if (backBtn) backBtn.onclick = _spesaScanTarget ? () => { _spesaScanTarget = null; showPage('shopping'); } : () => showPage('scan');
// Show "shopping target" banner if we came from the shopping list
const banner = document.getElementById('shopping-scan-target-banner');
if (banner && _spesaScanTarget) {
const targetName = _spesaScanTarget.name;
banner.style.display = 'block';
banner.innerHTML = `
<div class="shopping-scan-target-info">
<span class="stb-label">🛒 ${t('shopping.scan_target_label')}</span>
<span class="stb-name">${escapeHtml(targetName)}</span>
</div>
<div class="shopping-scan-target-actions">
<button class="btn btn-success stb-btn" onclick="confirmShoppingItemFound()">✅ ${t('shopping.scan_target_found')}</button>
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>showPage('scan')">✕ ${t('btn.cancel')}</button>
</div>
`;
} else if (banner) {
banner.style.display = 'none';
}
showPage('action');
}
// Check if product exists in inventory
async function checkInventoryForProduct(productId) {
try {
const data = await api('inventory_list');
return (data.inventory || []).filter(i => i.product_id == productId);
} catch(e) {
return [];
}
}
// === EDIT PRODUCT FROM ACTION PAGE ===
function editProductFromAction() {
if (!currentProduct) return;
// Pre-fill the product form with current product data
document.getElementById('pf-id').value = currentProduct.id || '';
document.getElementById('pf-name').value = currentProduct.name || '';
document.getElementById('pf-brand').value = currentProduct.brand || '';
document.getElementById('pf-barcode').value = currentProduct.barcode || '';
document.getElementById('pf-image').value = '';
document.getElementById('pf-notes').value = currentProduct.notes || '';
document.getElementById('pf-unit').value = currentProduct.unit || 'pz';
document.getElementById('pf-defqty').value = currentProduct.default_quantity || 1;
document.getElementById('product-form-title').textContent = t('product.title_edit');
const pfAiRow = document.getElementById('pf-ai-fill-row');
if (pfAiRow) pfAiRow.style.display = 'none';
// Keep barcode hint hidden in edit mode
const pfBcHint = document.getElementById('pf-barcode-hint');
if (pfBcHint) pfBcHint.style.display = 'none';
// Restore datalist for editing (was removed for new products)
document.getElementById('pf-name').setAttribute('list', 'common-products');
document.getElementById('pf-brand').setAttribute('list', 'common-brands');
// Set category
const cat = mapToLocalCategory(currentProduct.category, currentProduct.name);
document.getElementById('pf-category').value = cat;
document.getElementById('pf-category').dataset.manuallySet = 'true';
document.getElementById('pf-defqty').dataset.manuallySet = 'true';
// Image preview - not shown in edit mode
const preview = document.getElementById('pf-image-preview');
preview.style.display = 'none';
// Conf size row
const pfConfRow = document.getElementById('pf-conf-size-row');
if (currentProduct.unit === 'conf' && pfConfRow) {
pfConfRow.style.display = 'block';
document.getElementById('pf-conf-size').value = currentProduct.default_quantity || '';
document.getElementById('pf-conf-unit').value = currentProduct.package_unit || 'g';
} else if (pfConfRow) {
pfConfRow.style.display = 'none';
}
showPage('product-form');
}
// === EDIT INVENTORY ITEM FROM ACTION PAGE ===
// === OPEN INVENTORY EDIT — picks item or shows location picker ===
function openInventoryEdit() {
const items = _actionInventoryItems;
if (!items || items.length === 0) {
showToast('Nessuna voce di inventario trovata', 'error');
return;
}
if (items.length === 1) {
editActionInventoryItem(items[0].id);
return;
}
// Multiple locations → let user pick which one to edit
const contentEl = document.getElementById('modal-content');
contentEl.innerHTML = `
<div class="modal-header">
<h3>✏️ Quale modifica?</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:0.9rem;color:var(--text-muted);margin:0 0 12px">Scegli la posizione da modificare:</p>
<div style="display:flex;flex-direction:column;gap:8px">
${items.map(inv => {
const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location };
const qtyStr = formatQuantity(inv.quantity, inv.unit, inv.default_quantity, inv.package_unit);
let expiryStr = '';
if (inv.expiry_date) {
const d = daysUntilExpiry(inv.expiry_date);
expiryStr = ` · ${d < 0 ? t('expiry.badge_expired_bare') : '📅 ' + formatDate(inv.expiry_date)}`;
}
const vacuumStr = inv.vacuum_sealed ? ' 🫙' : '';
return `<button class="btn btn-secondary full-width" style="justify-content:flex-start;gap:10px;text-align:left"
onclick="editActionInventoryItem(${inv.id})">
<span style="font-size:1.3rem">${locInfo.icon}</span>
<span><strong>${locInfo.label}</strong>${vacuumStr}<br>
<small style="color:var(--text-muted)">${qtyStr}${expiryStr}</small></span>
</button>`;
}).join('')}
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
function editActionInventoryItem(inventoryId) {
const item = _actionInventoryItems.find(i => i.id === inventoryId);
if (!item) return;
const isConf = (item.unit || 'pz') === 'conf';
const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : '';
const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g';
window._editingProduct = { name: item.name || currentProduct.name, category: item.category || currentProduct.category || '' };
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>Modifica ${escapeHtml(item.name || currentProduct.name)}</h3>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<form class="form" onsubmit="submitActionEditInventory(event, ${inventoryId}, ${item.product_id})">
<div class="form-group">
<label>${t('add.quantity_label')}</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', -1)"></button>
<input type="number" id="action-edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', 1)">+</button>
</div>
</div>
<div class="form-group">
<label>${t('product.unit_label')}</label>
<select id="action-edit-unit" class="form-input" onchange="onActionEditUnitChange()">
${['pz','g','ml','conf'].map(u => `<option value="${u}" ${(item.unit||'pz') === u ? 'selected' : ''}>${u === 'pz' ? 'pz (pezzi)' : u === 'g' ? 'g (grammi)' : u === 'ml' ? 'ml (millilitri)' : u === 'conf' ? 'conf (confezioni)' : u}</option>`).join('')}
</select>
</div>
<div class="form-group" id="action-edit-conf-group" style="display:${isConf ? 'block' : 'none'}">
<label>📦 Ogni confezione contiene:</label>
<div class="conf-size-inputs">
<input type="number" id="action-edit-conf-size" class="form-input conf-size-input" min="1" step="any" value="${confSizeVal}" placeholder="es. 300">
<select id="action-edit-conf-unit" class="form-input conf-size-unit">
${['g','ml'].map(u => `<option value="${u}" ${confUnitVal === u ? 'selected' : ''}>${u}</option>`).join('')}
</select>
</div>
</div>
<div class="form-group">
<label>${t('inventory.label_position')}</label>
<div class="location-selector">
${Object.entries(LOCATIONS).map(([k, v]) => `
<button type="button" class="loc-btn ${item.location === k ? 'active' : ''}"
onclick="this.parentElement.querySelectorAll('.loc-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('action-edit-loc').value='${k}';recalcEditExpiry('action-edit-loc','action-edit-vacuum','action-edit-expiry')">${v.icon} ${v.label}</button>
`).join('')}
</div>
<input type="hidden" id="action-edit-loc" value="${item.location}">
</div>
<div class="form-group">
<label>${t('inventory.label_expiry')}</label>
<input type="date" id="action-edit-expiry" value="${item.expiry_date || ''}" class="form-input">
</div>
<div class="form-group">
<label class="toggle-row">
${t('add.vacuum_label')}
<span class="toggle-switch">
<input type="checkbox" id="action-edit-vacuum" ${item.vacuum_sealed ? 'checked' : ''} onchange="recalcEditExpiry('action-edit-loc','action-edit-vacuum','action-edit-expiry')">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div class="modal-actions" style="margin-top:12px">
<button type="submit" class="btn btn-large btn-primary flex-1">${t('btn.save')}</button>
<button type="button" class="btn btn-secondary" onclick="deleteActionInventoryItem(${inventoryId})" style="padding:12px">🗑️</button>
</div>
</form>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
function onActionEditUnitChange() {
const unit = document.getElementById('action-edit-unit').value;
const confGroup = document.getElementById('action-edit-conf-group');
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
}
async function submitActionEditInventory(e, id, productId) {
e.preventDefault();
const qty = parseFloat(document.getElementById('action-edit-qty').value);
const loc = document.getElementById('action-edit-loc').value;
const expiry = document.getElementById('action-edit-expiry').value || null;
const unit = document.getElementById('action-edit-unit').value;
const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId,
vacuum_sealed: document.getElementById('action-edit-vacuum')?.checked ? 1 : 0 };
if (unit === 'conf') {
payload.package_unit = document.getElementById('action-edit-conf-unit')?.value || '';
payload.package_size = parseFloat(document.getElementById('action-edit-conf-size')?.value) || 0;
} else {
payload.package_unit = '';
payload.package_size = 0;
}
await api('inventory_update', {}, 'POST', payload);
closeModal();
showToast('Aggiornato!', 'success');
showProductAction(); // Refresh the action page
}
async function deleteActionInventoryItem(id) {
if (confirm(t('confirm.remove_item'))) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast(t('toast.product_removed'), 'success');
showProductAction(); // Refresh the action page
}
}
// === THROW AWAY FORM ===
function showThrowForm() {
// Open a modal to ask how much to throw away
api('inventory_list').then(data => {
const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id);
if (items.length === 0) {
showToast('Prodotto non nell\'inventario', 'error');
return;
}
const totalQty = items.reduce((sum, i) => sum + parseFloat(i.quantity), 0);
const unit = items[0].unit || 'pz';
const defQty = items[0].default_quantity || 0;
const pkgUnit = items[0].package_unit || '';
const qtyDisplay = formatQuantity(totalQty, unit, defQty, pkgUnit);
let locOptionsHtml = items.map(inv => {
const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location };
return `<div class="inv-status-item"><span>${locInfo.icon} ${locInfo.label}</span><span class="inv-status-qty">${formatQuantity(inv.quantity, inv.unit, inv.default_quantity, inv.package_unit)}</span></div>`;
}).join('');
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${t('use.throw_title')}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="product-preview-small" style="margin-bottom:12px">
${currentProduct.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="" style="width:50px;height:50px;border-radius:10px;object-fit:cover">` :
`<span style="font-size:2rem">${CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || '📦'}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct.name)}</h3>
<p>Disponibile: <strong>${qtyDisplay}</strong></p>
</div>
</div>
<div class="inventory-status-bar" style="margin-bottom:16px">
<div class="inv-status-items">${locOptionsHtml}</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<button class="btn btn-large btn-danger full-width" onclick="throwAll()">
${t('use.throw_all', { qty: qtyDisplay })}
</button>
<div style="text-align:center;color:var(--text-muted);font-size:0.85rem">${t('use.throw_qty_hint')}</div>
<div class="form-group">
<label>📍 Da dove?</label>
<div class="location-selector" id="throw-location-selector">
${items.map((inv, idx) => {
const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location };
return `<button type="button" class="loc-btn ${idx === 0 ? 'active' : ''}" onclick="selectThrowLocation(this, '${inv.location}')">${locInfo.icon} ${locInfo.label} (${formatQuantity(inv.quantity, inv.unit, inv.default_quantity, inv.package_unit)})</button>`;
}).join('')}
</div>
<input type="hidden" id="throw-location" value="${items[0].location}">
</div>
<div class="form-group">
<label>${t('use.throw_qty_label')}</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', -1)"></button>
<input type="number" id="throw-quantity" value="1" min="0.1" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', 1)">+</button>
</div>
</div>
<button class="btn btn-large btn-warning full-width" onclick="throwPartial()">
${t('use.throw_partial_btn')}
</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
});
}
function selectThrowLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('throw-location').value = loc;
}
/**
* Show a destructive-action confirmation modal with a 5-second auto-confirm countdown.
* The user can tap "Annulla" to cancel or "Conferma" (or wait) to proceed.
* @param {string} title — Modal title
* @param {string} msg — Explanatory text
* @param {Function} onConfirm — Called when confirmed (by user or countdown)
* @param {string} [confirmLabel] — Override confirm button label
*/
function _showDestructiveConfirm(title, msg, onConfirm, confirmLabel) {
const DURATION = 5000;
const btnLabel = confirmLabel || t('confirm.proceed') || 'Conferma';
const cancelLabel = t('confirm.cancel') || 'Annulla';
let rafHandle = null;
let timerHandle = null;
let resolved = false;
const overlayEl = document.getElementById('modal-overlay');
const contentEl = document.getElementById('modal-content');
const confirmBtnId = '_destConfirmBtn_' + Date.now();
const barId = '_destConfirmBar_' + Date.now();
contentEl.innerHTML = `
<div class="modal-header">
<h3>${escapeHtml(title)}</h3>
</div>
<p style="margin:12px 0 18px;color:var(--text-muted);font-size:0.95rem">${escapeHtml(msg)}</p>
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-bottom:16px">
<div id="${barId}" style="height:100%;width:100%;background:var(--danger);transition:none"></div>
</div>
<div style="display:flex;gap:10px">
<button class="btn btn-secondary" style="flex:1" id="_destCancelBtn">${escapeHtml(cancelLabel)}</button>
<button class="btn btn-danger" style="flex:1" id="${confirmBtnId}">${escapeHtml(btnLabel)}</button>
</div>
`;
overlayEl.style.display = 'flex';
function cleanup() {
if (rafHandle) cancelAnimationFrame(rafHandle);
if (timerHandle) clearTimeout(timerHandle);
rafHandle = timerHandle = null;
}
function doConfirm() {
if (resolved) return;
resolved = true;
cleanup();
closeModal();
onConfirm();
}
function doCancel() {
if (resolved) return;
resolved = true;
cleanup();
closeModal();
}
document.getElementById(confirmBtnId).addEventListener('click', doConfirm);
document.getElementById('_destCancelBtn').addEventListener('click', doCancel);
// Countdown animation
const barEl = document.getElementById(barId);
const start = performance.now();
function tick() {
const pct = Math.min(100, (performance.now() - start) / DURATION * 100);
if (barEl) barEl.style.width = (100 - pct) + '%';
if (pct < 100) { rafHandle = requestAnimationFrame(tick); }
}
rafHandle = requestAnimationFrame(tick);
timerHandle = setTimeout(doConfirm, DURATION);
}
async function throwAll() {
const name = currentProduct ? currentProduct.name : '';
_showDestructiveConfirm(
t('use.throw_all_confirm_title') || '🗑️ Butta tutto',
(t('use.throw_all_confirm_msg') || 'Vuoi davvero buttare via tutto il prodotto?') + (name ? `\n"${name}"` : ''),
async () => {
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location: '__all__',
notes: 'Buttato'
});
showLoading(false);
if (result.success) {
showToast(t('toast.thrown_away', { name: currentProduct.name }), 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
},
t('use.throw_all_confirm_btn') || '🗑️ Sì, butta'
);
}
async function throwPartial() {
const qty = parseFloat(document.getElementById('throw-quantity').value) || 1;
const loc = document.getElementById('throw-location').value;
closeModal();
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
quantity: qty,
location: loc,
notes: 'Buttato'
});
showLoading(false);
if (result.success) {
showToast(t('toast.thrown_away_partial', { qty, unit: currentProduct.unit || 'pz', name: currentProduct.name }), 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
function toggleActionEdit() {
const el = document.getElementById('action-edit-info');
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
if (el.style.display === 'block') {
setTimeout(() => document.getElementById('edit-action-name')?.focus(), 100);
}
}
async function saveEditedProductInfo() {
const name = (document.getElementById('edit-action-name')?.value || '').trim();
if (!name) {
showToast(t('product.name_required'), 'error');
document.getElementById('edit-action-name')?.focus();
return;
}
const brand = (document.getElementById('edit-action-brand')?.value || '').trim();
const category = document.getElementById('edit-action-category')?.value || '';
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
id: currentProduct.id,
barcode: currentProduct.barcode || null,
name: name,
brand: brand,
category: category || currentProduct.category || '',
image_url: currentProduct.image_url || '',
unit: currentProduct.unit || 'pz',
default_quantity: currentProduct.default_quantity || 1,
notes: currentProduct.notes || '',
});
showLoading(false);
if (result.success) {
// Update current product in memory
currentProduct.name = name;
currentProduct.brand = brand;
if (category) currentProduct.category = category;
showToast(t('toast.product_updated'), 'success');
// Refresh the action page with updated data
showProductAction();
} else {
showToast(result.error || t('error.save'), 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
// ===== ADD TO INVENTORY =====
function showAddForm() {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || '📦';
document.getElementById('add-product-preview').innerHTML = `
${currentProduct.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span style="font-size:2rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct.name)}</h3>
<p>${currentProduct.brand ? escapeHtml(currentProduct.brand) : ''}</p>
${currentProduct.weight_info ? `<p style="font-size:0.8rem;color:var(--text-light)">${escapeHtml(currentProduct.weight_info)}</p>` : ''}
</div>
`;
// Set unit selector
const unit = currentProduct.unit || 'pz';
const unitSelect = document.getElementById('add-unit');
unitSelect.value = unit;
document.getElementById('add-quantity').value = unit === 'conf' ? (currentProduct._confCount || currentProduct.last_qty || 1) : (currentProduct.default_quantity || 1);
document.getElementById('add-quantity').dataset.manuallySet = 'false';
// Show/hide conf size row and pre-fill
const confRow = document.getElementById('add-conf-size-row');
if (confRow) {
confRow.style.display = unit === 'conf' ? 'block' : 'none';
if (unit === 'conf' && currentProduct.package_unit && currentProduct.default_quantity > 0) {
document.getElementById('add-conf-size').value = currentProduct.default_quantity;
document.getElementById('add-conf-unit').value = currentProduct.package_unit;
} else if (unit === 'conf' && ['g', 'ml', 'kg', 'l'].includes(currentProduct.unit) && currentProduct.default_quantity > 0) {
// Product was defined in weight/volume — that quantity IS the package size
document.getElementById('add-conf-size').value = currentProduct.default_quantity;
document.getElementById('add-conf-unit').value = currentProduct.unit;
} else if (unit === 'conf') {
document.getElementById('add-conf-size').value = '';
document.getElementById('add-conf-unit').value = 'g';
}
}
// Track manual edits to quantity in add form
const addQtyInput = document.getElementById('add-quantity');
addQtyInput.removeEventListener('input', markAddQtyManuallySet);
addQtyInput.addEventListener('input', markAddQtyManuallySet);
// Show weight info if product has it
const weightInfoEl = document.getElementById('add-weight-info');
if (currentProduct.weight_info) {
weightInfoEl.textContent = `📦 Confezione: ${currentProduct.weight_info}`;
weightInfoEl.style.display = 'block';
} else {
weightInfoEl.style.display = 'none';
}
// Set qty step based on selected unit
updateAddQtyStep();
// Auto-detect location
const autoLoc = guessLocation(currentProduct);
document.getElementById('add-location').value = autoLoc;
// Highlight correct location button
document.querySelectorAll('#page-add .loc-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#page-add .loc-btn').forEach(b => {
const btnText = b.textContent.toLowerCase();
if (btnText.includes(autoLoc)) b.classList.add('active');
});
// Show the purchase-type selector
const expirySection = document.getElementById('add-expiry-section');
const estimatedDays = estimateExpiryDays(currentProduct, autoLoc);
const estimatedDate = addDays(estimatedDays);
const estimateLabel = formatEstimatedExpiry(estimatedDays);
let expirySuffix = autoLoc === 'freezer' ? ' (freezer)' : '';
// Reset vacuum sealed toggle
const vacuumCb = document.getElementById('add-vacuum-sealed');
if (vacuumCb) {
vacuumCb.checked = false;
document.getElementById('add-vacuum-hint').style.display = 'none';
}
// Reset historical expiry for this product; will be fetched async
window._historyExpiryDays = null;
window._historyExpiryCount = 0;
// Reset extra batches from previous add
window._addExtraBatches = [];
// Store base expiry for vacuum recalculation
window._addBaseExpiryDays = estimatedDays;
expirySection.innerHTML = `
<label>${t('add.purchase_type_label')}</label>
<div class="purchase-type-selector">
<button type="button" class="purchase-type-btn active" onclick="selectPurchaseType(this, 'new')">
${t('add.new_btn')}
</button>
<button type="button" class="purchase-type-btn" onclick="selectPurchaseType(this, 'existing')">
${t('add.existing_btn')}
</button>
</div>
<div id="expiry-detail" class="expiry-detail">
<div class="expiry-estimate">
<span class="expiry-estimate-label">${t('add.estimated_expiry')} <strong>${estimateLabel}${expirySuffix}</strong></span>
<span class="expiry-estimate-date">${formatDate(estimatedDate)}</span>
</div>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="${estimatedDate}">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="${t('add.scan_expiry_title')}">📷</button>
</div>
<p class="form-hint">${t('add.hint_modify')}</p>
</div>
<div id="multi-batch-section" style="display:${unit === 'conf' ? 'block' : 'none'}">
<div id="multi-batch-container"></div>
<button type="button" class="btn btn-outline btn-small full-width" style="margin-top:8px" onclick="addExpiryBatch()">
📦 + Lotto con scadenza diversa
</button>
</div>
`;
showPage('add');
updateScaleReadButtons();
// After rendering, fetch history-based expiry prediction
if (currentProduct && currentProduct.id) {
_fetchExpiryHistoryAndUpdate(currentProduct.id);
}
// If Gemini is available and product was just created (no history), ask for AI hint
if (_geminiAvailable && currentProduct && !currentProduct._aiHintFetched) {
_applyAIProductHint();
}
}
function toggleVacuumSealed() {
const cb = document.getElementById('add-vacuum-sealed');
if (cb) cb.checked = !cb.checked;
onVacuumSealedChange();
}
function onVacuumSealedChange() {
const hint = document.getElementById('add-vacuum-hint');
if (hint) hint.style.display = document.getElementById('add-vacuum-sealed')?.checked ? 'block' : 'none';
recalculateAddExpiry();
}
function recalculateAddExpiry() {
if (!currentProduct) return;
const loc = document.getElementById('add-location')?.value || '';
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
const baseDays = window._historyExpiryDays ?? estimateExpiryDays(currentProduct, loc);
let days = isVacuum ? getVacuumExpiryDays(baseDays) : baseDays;
window._addBaseExpiryDays = baseDays;
const newDate = addDays(days);
const newLabel = formatEstimatedExpiry(days);
let suffix = '';
if (window._historyExpiryDays) suffix = ' (da storico)';
else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum');
else if (loc === 'freezer') suffix = ' (freezer)';
else if (isVacuum) suffix = ' (sotto vuoto)';
const expiryInput = document.getElementById('add-expiry');
const estimateEl = document.querySelector('.expiry-estimate-label');
const dateEl = document.querySelector('.expiry-estimate-date');
if (expiryInput) expiryInput.value = newDate;
if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} <strong>${newLabel}${suffix}</strong>`;
if (dateEl) dateEl.textContent = formatDate(newDate);
}
async function _fetchExpiryHistoryAndUpdate(productId) {
try {
const res = await fetch(`api/index.php?action=expiry_history&product_id=${encodeURIComponent(productId)}`);
const data = await res.json();
if (data.avg_days && data.avg_days > 0 && data.count >= 1) {
window._historyExpiryDays = data.avg_days;
window._historyExpiryCount = data.count;
// Update the displayed date and label
const loc = document.getElementById('add-location')?.value || '';
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
let days = isVacuum ? getVacuumExpiryDays(data.avg_days) : data.avg_days;
const newDate = addDays(days);
const newLabel = formatEstimatedExpiry(days);
const suffix = ` <span class="history-badge" title="Media da ${data.count} insertiment${data.count === 1 ? 'o' : 'i'} precedent${data.count === 1 ? 'e' : 'i'}">📊 storico</span>`;
const expiryInput = document.getElementById('add-expiry');
const estimateEl = document.querySelector('.expiry-estimate-label');
const dateEl = document.querySelector('.expiry-estimate-date');
if (expiryInput) expiryInput.value = newDate;
if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} <strong>${newLabel}${suffix}</strong>`;
if (dateEl) dateEl.textContent = formatDate(newDate);
window._addBaseExpiryDays = data.avg_days;
}
} catch (e) {
// silently fall back to rule-based estimate
}
}
// ===== AI PRODUCT HINT: shelf-life + storage suggestion =====
let _aiProductHintController = null;
async function _applyAIProductHint() {
if (!currentProduct) return;
// Abort any in-flight request for a previous product
if (_aiProductHintController) _aiProductHintController.abort();
_aiProductHintController = new AbortController();
// Show a subtle loading indicator near the estimate label
const estimateEl = document.querySelector('.expiry-estimate-label');
if (estimateEl) {
const oldHtml = estimateEl.innerHTML;
estimateEl.dataset.aiOriginal = oldHtml;
estimateEl.innerHTML += ' <span id="ai-hint-loading" style="font-size:0.75rem;opacity:0.7">🤖…</span>';
}
try {
const data = await api('gemini_product_hint', {}, 'POST', {
name: currentProduct.name,
category: currentProduct.category || '',
lang: _currentLang,
});
// Remove loading indicator
document.getElementById('ai-hint-loading')?.remove();
if (!data.success || !data.location || !data.expiry_days) return;
// Mark so we don't re-fetch on the same product
currentProduct._aiHintFetched = true;
const curLoc = document.getElementById('add-location')?.value;
const locChanged = data.location !== curLoc;
// Update location if AI suggests a different one (and user hasn't manually picked)
if (locChanged) {
document.getElementById('add-location').value = data.location;
// Update active loc-btn
document.querySelectorAll('#page-add .loc-btn').forEach(b => {
const onclick = b.getAttribute('onclick') || '';
const locMatch = onclick.match(/'([^']+)'\s*\)/);
if (locMatch) b.classList.toggle('active', locMatch[1] === data.location);
});
}
// Update expiry only if we have no historical data (history takes priority)
if (!window._historyExpiryDays) {
window._addBaseExpiryDays = data.expiry_days;
const newDate = addDays(data.expiry_days);
const newLabel = formatEstimatedExpiry(data.expiry_days);
const expiryInput = document.getElementById('add-expiry');
const dateEl = document.querySelector('.expiry-estimate-date');
if (expiryInput) expiryInput.value = newDate;
if (dateEl) dateEl.textContent = formatDate(newDate);
const aiSuffix = ` <span class="history-badge" style="background:rgba(99,102,241,0.15);color:#6366f1" title="${escapeHtml(data.reason || '')}">🤖 AI</span>`;
if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} <strong>${newLabel}</strong>${aiSuffix}`;
} else if (estimateEl && estimateEl.dataset.aiOriginal) {
// Restore original if history already set
estimateEl.innerHTML = estimateEl.dataset.aiOriginal;
}
// Show a toast only if location changed
if (locChanged) {
const locLabels = { dispensa: t('location.dispensa') || 'Dispensa', frigo: t('location.frigo') || 'Frigo', freezer: t('location.freezer') || 'Freezer' };
showToast(`🤖 AI: conserva in ${locLabels[data.location] || data.location}`, 'info', 4000);
}
} catch (e) {
document.getElementById('ai-hint-loading')?.remove();
if (estimateEl && estimateEl.dataset.aiOriginal) estimateEl.innerHTML = estimateEl.dataset.aiOriginal;
// silent — AI hint is best-effort
}
}
function getVacuumExpiryDays(baseDays) {
// Vacuum sealing extends shelf life significantly
if (baseDays <= 7) return Math.round(baseDays * 3); // very fresh: 3x (e.g., 3→9, 7→21)
if (baseDays <= 14) return Math.round(baseDays * 3); // fresh cheese/dairy: 3x (10→30)
if (baseDays <= 30) return Math.round(baseDays * 2.5); // short: 2.5x (e.g., 21→52)
if (baseDays <= 90) return Math.round(baseDays * 2.5); // medium (cheese ~60d): 2.5x (60→150)
return Math.round(baseDays * 1.5); // long-lasting: 1.5x
}
function onAddUnitChange() {
updateAddQtyStep();
const unit = document.getElementById('add-unit').value;
const qtyInput = document.getElementById('add-quantity');
// Show/hide conf size row
const confRow = document.getElementById('add-conf-size-row');
if (confRow) {
const isConf = unit === 'conf';
confRow.style.display = isConf ? 'block' : 'none';
// Pre-fill from currentProduct if available
if (isConf && currentProduct) {
const sizeInput = document.getElementById('add-conf-size');
const unitSelect = document.getElementById('add-conf-unit');
if (currentProduct.package_unit && currentProduct.default_quantity > 1) {
sizeInput.value = currentProduct.default_quantity;
unitSelect.value = currentProduct.package_unit;
} else if (['g', 'ml', 'kg', 'l'].includes(currentProduct.unit) && currentProduct.default_quantity > 0) {
// Product was defined in weight/volume — that quantity IS the package size
sizeInput.value = currentProduct.default_quantity;
unitSelect.value = currentProduct.unit;
} else {
sizeInput.value = '';
unitSelect.value = 'g';
}
}
// Scroll into view so the user sees the new field
if (isConf) setTimeout(() => confRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 100);
}
// Show/hide multi-batch section (only for conf unit)
const mbSection = document.getElementById('multi-batch-section');
if (mbSection) mbSection.style.display = unit === 'conf' ? 'block' : 'none';
// If switching units, suggest a sensible quantity
// BUT only if the user hasn't manually changed the quantity in this form
if (qtyInput.dataset.manuallySet === 'true') return; // User already edited qty, don't overwrite
const currentQty = parseFloat(qtyInput.value) || 1;
// Convert between related units if logical
if (unit === 'g' && currentQty <= 10) qtyInput.value = currentProduct.weight_info ? parseFloat(currentProduct.weight_info) || 250 : 250;
if (unit === 'ml' && currentQty <= 10) qtyInput.value = 500;
if (unit === 'pz' && currentQty > 100) qtyInput.value = 1;
if (unit === 'conf' && currentQty > 10) qtyInput.value = 1;
// Show/hide scale read button based on new unit
updateScaleReadButtons();
}
function updateAddQtyStep() {
const qtyInput = document.getElementById('add-quantity');
const unit = document.getElementById('add-unit').value;
qtyInput.step = 'any';
if (unit === 'g' || unit === 'ml') {
qtyInput.min = '1';
} else {
qtyInput.min = '1';
}
}
function markAddQtyManuallySet() {
document.getElementById('add-quantity').dataset.manuallySet = 'true';
}
function adjustAddQty(delta) {
const qtyInput = document.getElementById('add-quantity');
qtyInput.dataset.manuallySet = 'true'; // +/- buttons count as manual edit
const unit = document.getElementById('add-unit').value;
let val = parseFloat(qtyInput.value) || 0;
let step;
if (unit === 'g' || unit === 'ml') {
step = val < 50 ? 1 : (val < 500 ? 10 : 50);
} else {
step = 1;
}
val = Math.max(parseFloat(qtyInput.min) || 0.1, val + delta * step);
// Round nicely
if (step >= 1) val = Math.round(val);
else val = Math.round(val * 10) / 10;
qtyInput.value = val;
}
function selectPurchaseType(btn, type) {
btn.parentElement.querySelectorAll('.purchase-type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Reset extra batches when switching purchase type
window._addExtraBatches = [];
const mbContainer = document.getElementById('multi-batch-container');
if (mbContainer) mbContainer.innerHTML = '';
const detailDiv = document.getElementById('expiry-detail');
// Save current quantity before switching, so we can preserve it
const currentQty = document.getElementById('add-quantity').value;
if (type === 'new') {
// Recalculate fresh expiry based on current location/vacuum
const loc = document.getElementById('add-location')?.value || '';
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
const baseDays = window._historyExpiryDays ?? estimateExpiryDays(currentProduct, loc);
let days = isVacuum ? getVacuumExpiryDays(baseDays) : baseDays;
const estimatedDate = addDays(days);
const estimateLabel = formatEstimatedExpiry(days);
let suffix = '';
if (window._historyExpiryDays) suffix = ` <span class="history-badge" title="Media da ${window._historyExpiryCount} inserimento/i precedente/i">📊 storico</span>`;
else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum');
else if (loc === 'freezer') suffix = ' ' + t('add.suffix_freezer');
else if (isVacuum) suffix = ' ' + t('add.suffix_vacuum');
detailDiv.innerHTML = `
<div class="expiry-estimate">
<span class="expiry-estimate-label">${t('add.estimated_expiry')} <strong>${estimateLabel}${suffix}</strong></span>
<span class="expiry-estimate-date">${formatDate(estimatedDate)}</span>
</div>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="${estimatedDate}">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="${t('add.scan_expiry_title')}">📷</button>
</div>
<p class="form-hint">${t('add.hint_modify')}</p>
`;
// Restore quantity - switching purchase type should NOT change it
document.getElementById('add-quantity').value = currentQty;
// Show multi-batch section only in "new" mode (and only for conf unit)
const mbSection = document.getElementById('multi-batch-section');
if (mbSection) mbSection.style.display = (document.getElementById('add-unit')?.value === 'conf') ? 'block' : 'none';
} else {
detailDiv.innerHTML = `
<div class="form-group">
<label>📅 Quando scade?</label>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="${t('add.scan_expiry_title')}">📷</button>
</div>
<p class="form-hint">Inserisci la data di scadenza o scansionala</p>
</div>
<div class="form-group">
<label>${t('add.remaining_label')}</label>
<p class="form-hint" style="margin-bottom:6px">${t('add.remaining_hint')}</p>
<div class="remaining-options">
<button type="button" class="remaining-btn" onclick="setRemainingPct(1)">${t('add.remaining_full')}</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.75)">🟡 ¾</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.5)">${t('add.remaining_half')}</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.25)">🔴 ¼</button>
</div>
</div>
`;
// DON'T auto-set remaining percentage - keep the quantity the user already entered
// Hide multi-batch section in "existing" mode
const mbSection = document.getElementById('multi-batch-section');
if (mbSection) mbSection.style.display = 'none';
}
}
function setRemainingPct(pct) {
document.querySelectorAll('.remaining-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
const baseQty = currentProduct.default_quantity || 1;
const unit = currentProduct.unit || 'pz';
let adjustedQty;
if (unit === 'pz' || unit === 'conf') {
adjustedQty = Math.max(1, Math.round(baseQty * pct));
} else {
adjustedQty = Math.round(baseQty * pct * 10) / 10;
}
document.getElementById('add-quantity').value = adjustedQty;
}
// ===== MULTI-EXPIRY BATCHES (for conf products with different expiry dates) =====
window._addExtraBatches = [];
function addExpiryBatch() {
const loc = document.getElementById('add-location')?.value || '';
const baseDays = window._historyExpiryDays ?? estimateExpiryDays(currentProduct, loc);
const estimatedDate = addDays(baseDays);
window._addExtraBatches.push({ qty: 1, expiry: estimatedDate });
_rebuildMultiBatchUI();
}
function removeExpiryBatch(i) {
window._addExtraBatches.splice(i, 1);
_rebuildMultiBatchUI();
}
function adjustBatchQty(i, delta) {
window._addExtraBatches[i].qty = Math.max(1, (window._addExtraBatches[i].qty || 1) + delta);
_rebuildMultiBatchUI();
}
function _rebuildMultiBatchUI() {
const container = document.getElementById('multi-batch-container');
if (!container) return;
if (window._addExtraBatches.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = window._addExtraBatches.map((b, i) => `
<div class="multi-batch-row">
<div class="multi-batch-qty">
<button type="button" class="qty-btn" onclick="adjustBatchQty(${i}, -1)"></button>
<input type="number" class="qty-input" value="${b.qty}" min="1" step="1" style="width:60px"
onchange="window._addExtraBatches[${i}].qty = parseInt(this.value)||1">
<button type="button" class="qty-btn" onclick="adjustBatchQty(${i}, 1)">+</button>
<span class="multi-batch-unit">conf</span>
</div>
<input type="date" class="form-input multi-batch-date" value="${b.expiry}"
onchange="window._addExtraBatches[${i}].expiry = this.value">
<button type="button" class="btn-icon-sm" onclick="removeExpiryBatch(${i})" title="Rimuovi">✕</button>
</div>
`).join('');
}
function selectLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('add-location').value = loc;
recalculateAddExpiry();
}
async function submitAdd(e) {
e.preventDefault();
showLoading(true);
try {
const selectedUnit = document.getElementById('add-unit').value;
const productUnit = currentProduct.unit || 'pz';
// Validate conf fields
if (selectedUnit === 'conf') {
const confSize = parseFloat(document.getElementById('add-conf-size')?.value);
if (!confSize || confSize <= 0) {
showLoading(false);
showToast('Specifica il contenuto di ogni confezione', 'error');
document.getElementById('add-conf-size')?.focus();
return;
}
}
const result = await api('inventory_add', {}, 'POST', {
product_id: currentProduct.id,
quantity: parseFloat(document.getElementById('add-quantity').value) || 1,
location: document.getElementById('add-location').value,
expiry_date: document.getElementById('add-expiry').value || null,
unit: selectedUnit !== productUnit ? selectedUnit : null,
package_unit: selectedUnit === 'conf' ? (document.getElementById('add-conf-unit')?.value || null) : null,
package_size: selectedUnit === 'conf' ? (parseFloat(document.getElementById('add-conf-size')?.value) || null) : null,
vacuum_sealed: document.getElementById('add-vacuum-sealed')?.checked ? 1 : 0,
});
showLoading(false);
if (result.success) {
// Build quantity info for toast
let qtyInfo = '';
if (result.total_qty) {
const u = result.unit || 'pz';
const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml', 'conf': 'conf' };
const uLabel = unitLabels[u] || u;
if (u === 'conf' && result.package_unit && result.default_quantity > 0) {
const pkgLabel = unitLabels[result.package_unit] || result.package_unit;
qtyInfo = ` (totale: ${result.total_qty} ${uLabel} da ${result.default_quantity}${pkgLabel})`;
} else {
qtyInfo = ` (totale: ${result.total_qty} ${uLabel})`;
}
}
showToast(t('add.product_added').replace('{name}', currentProduct.name).replace('{qty}', qtyInfo), 'success');
if (result.removed_from_bring) {
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
} else if (shoppingItems.length > 0 && shoppingListUUID) {
// PHP matching may have missed the item (custom name / no catalog match) —
// try a client-side fuzzy remove using the already-loaded shoppingItems
const match = _findSimilarItem(currentProduct.name, shoppingItems);
if (match) {
api('bring_remove', {}, 'POST', {
name: match.name,
rawName: match.rawName || '',
listUUID: shoppingListUUID
}).then(r => {
if (r && r.success) {
shoppingItems = shoppingItems.filter(i => i !== match);
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
}
}).catch(() => {});
}
}
if (!spesaModeAfterAdd()) showPage('dashboard');
// Submit extra batches (different expiry dates) in the background, silently
if ((window._addExtraBatches || []).length > 0) {
const loc = document.getElementById('add-location')?.value || result.location || 'dispensa';
const selectedUnit = document.getElementById('add-unit').value;
const productUnit = currentProduct.unit || 'pz';
const confUnit = document.getElementById('add-conf-unit')?.value || null;
const confSize = parseFloat(document.getElementById('add-conf-size')?.value) || null;
for (const batch of window._addExtraBatches) {
if (!batch.qty || batch.qty <= 0) continue;
api('inventory_add', {}, 'POST', {
product_id: currentProduct.id,
quantity: batch.qty,
location: loc,
expiry_date: batch.expiry || null,
unit: selectedUnit !== productUnit ? selectedUnit : null,
package_unit: selectedUnit === 'conf' ? confUnit : null,
package_size: selectedUnit === 'conf' ? confSize : null,
}).catch(() => {});
}
window._addExtraBatches = [];
}
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
// ===== 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';
document.getElementById('use-unit-switch').style.display = 'none';
// Reset location buttons
document.querySelectorAll('#page-use .loc-btn').forEach(b => b.classList.remove('active'));
document.querySelector('#page-use .loc-btn').classList.add('active');
loadUseInventoryInfo();
showPage('use');
updateScaleReadButtons();
}
function renderUsePreview() {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct?.category, currentProduct?.name)] || '📦';
document.getElementById('use-product-preview').innerHTML = `
${currentProduct?.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span style="font-size:2rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct?.name || '')}</h3>
<p>${currentProduct?.brand ? escapeHtml(currentProduct.brand) : ''}</p>
</div>
`;
}
// Conf-mode tracking for USE form
let _useConfMode = null; // null = normal, { packageSize, packageUnit, totalSub, unit } = conf mode active
let _useNormalUnit = 'pz'; // unit when not in conf mode
let _useCurrentItems = []; // cached inventory items for the current product on the use page
/**
* Mostra un suggerimento giallo sotto le info inventario quando ci sono più
* confezioni con scadenze diverse (o in posti diversi con scadenze diverse).
* Es: "⚠️ Usa prima quella in Frigo — scade il 12/04 (tra 3 giorni)!"
*/
function _renderUseExpiryHint(items) {
const hintEl = document.getElementById('use-expiry-hint');
// Parse YYYY-MM-DD as local noon to avoid timezone edge cases on some engines.
const parseLocalExpiryDate = (dateStr) => {
if (!dateStr) return null;
const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12, 0, 0, 0);
};
// Ignore tiny residual quantities to avoid misleading hints on near-zero leftovers.
const withExpiry = items.filter(i => i.expiry_date && parseFloat(i.quantity) > 0.01);
// Serve almeno 2 item con scadenze diverse (o locazioni diverse con scadenze)
if (withExpiry.length < 2) { hintEl.style.display = 'none'; return; }
const dates = withExpiry.map(i => i.expiry_date);
const uniqueDates = new Set(dates);
const uniqueLocs = new Set(withExpiry.map(i => i.location));
// Mostra hint se scadenze diverse OPPURE stessa scadenza ma luoghi diversi
if (uniqueDates.size < 2 && uniqueLocs.size < 2) { hintEl.style.display = 'none'; return; }
// Trova il più vicino alla scadenza
withExpiry.sort((a, b) => {
const da = parseLocalExpiryDate(a.expiry_date);
const db = parseLocalExpiryDate(b.expiry_date);
return (da ? da.getTime() : Infinity) - (db ? db.getTime() : Infinity);
});
const soonest = withExpiry[0];
const expDate = parseLocalExpiryDate(soonest.expiry_date);
if (!expDate || Number.isNaN(expDate.getTime())) { hintEl.style.display = 'none'; return; }
const today = new Date(); today.setHours(0,0,0,0);
const diffDays = Math.round((expDate - today) / 86400000);
const locInfo = LOCATIONS[soonest.location] || { icon: '📦', label: soonest.location };
const dateStr = expDate.toLocaleDateString(_currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT', { day: '2-digit', month: '2-digit' });
let whenStr;
if (diffDays < 0) whenStr = t('use.when_expired').replace('{n}', -diffDays);
else if (diffDays === 0) whenStr = t('use.when_today');
else if (diffDays === 1) whenStr = t('use.when_tomorrow');
else whenStr = t('use.when_days').replace('{n}', diffDays);
const locLabel = uniqueLocs.size > 1
? ` (${locInfo.icon} ${locInfo.label})`
: '';
hintEl.innerHTML = t('use.expiry_warning').replace('{loc}', locLabel).replace('{date}', `<strong>${dateStr}</strong>`).replace('{when}', whenStr);
hintEl.style.display = 'block';
}
function _isOpenedInventoryItem(item) {
const q = parseFloat(item.quantity);
const dq = parseFloat(item.default_quantity) || 0;
if (item.unit === 'conf' && dq > 0) return q !== Math.floor(q);
if (dq > 0) return Math.abs(q - Math.round(q / dq) * dq) > dq * 0.02;
return false;
}
function _locationHasOpenedPackage(items, location) {
return items.some(i => i.location === location && _isOpenedInventoryItem(i));
}
async function loadUseInventoryInfo() {
try {
const data = await api('inventory_list');
const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id);
_useCurrentItems = items; // cache for submitUseAll context detection
const infoEl = document.getElementById('use-inventory-info');
const unitSwitch = document.getElementById('use-unit-switch');
if (items.length === 0) {
infoEl.innerHTML = t('use.not_in_inventory');
unitSwitch.style.display = 'none';
_useConfMode = null;
document.getElementById('use-expiry-hint').style.display = 'none';
return;
}
// ── Suggerisci quale confezione usare per prima ──────────────────
_renderUseExpiryHint(items);
// ─────────────────────────────────────────────────────────────────
// Auto-select the location with an opened package first (use from opened before sealed)
const openedItem = items.find(_isOpenedInventoryItem);
const firstLoc = openedItem ? openedItem.location : items[0].location;
// Build location buttons only for locations where the product exists
const productLocations = [...new Set(items.map(i => i.location))];
const locSelector = document.getElementById('use-location-selector');
// Prefer the remembered location (if confirmed), else use the opened-package heuristic
const prefLoc = _getPreferredUseLocation(currentProduct.id);
const activeLoc = (prefLoc && productLocations.includes(prefLoc)) ? prefLoc : firstLoc;
document.getElementById('use-location').value = activeLoc;
// Builder for the full set of location buttons
const buildLocButtons = (active) => productLocations.map(loc => {
const locInfo = LOCATIONS[loc] || { icon: '📦', label: loc };
const locItems = items.filter(i => i.location === loc);
const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
const u = locItems[0].unit || 'pz';
const qtyLabel = formatQuantity(locQty, u, locItems[0].default_quantity, locItems[0].package_unit);
const openedBadge = _locationHasOpenedPackage(items, loc)
? ` <span class="loc-opened-badge">🔓 ${t('use.opened_badge')}</span>`
: '';
return `<button type="button" class="loc-btn ${loc === active ? 'active' : ''}${openedBadge ? ' loc-btn-opened' : ''}" onclick="selectUseLocation(this, '${loc}')">${locInfo.icon} ${locInfo.label}${openedBadge}<br><small>${qtyLabel}</small></button>`;
}).join('');
if (prefLoc && productLocations.includes(prefLoc) && productLocations.length > 1) {
// Confirmed preference → show collapsed row + hidden full picker
const locInfo = LOCATIONS[prefLoc] || { icon: '📦', label: prefLoc };
locSelector.innerHTML = `
<div class="pref-loc-info" id="pref-loc-info">
<span class="pref-loc-name">${locInfo.icon} ${locInfo.label}</span>
<button type="button" class="btn-link pref-loc-change" onclick="_expandUseLocationSelector()">${t('use.change')}</button>
</div>
<div id="pref-loc-full" style="display:none">${buildLocButtons(activeLoc)}</div>
`;
} else {
locSelector.innerHTML = buildLocButtons(activeLoc);
}
const unit = items[0].unit || 'pz';
const pkgSize = parseFloat(items[0].default_quantity) || 0;
const pkgUnit = items[0].package_unit || '';
const isConf = unit === 'conf' && pkgSize > 0 && pkgUnit;
if (isConf) {
// --- CONF MODE: show sub-unit controls ---
const totalConf = items.reduce((s, i) => s + parseFloat(i.quantity), 0);
const totalSub = totalConf * pkgSize;
const unitLabels = { 'ml': 'ml', 'g': 'g', 'pz': 'pz' };
const subLabel = unitLabels[pkgUnit] || pkgUnit;
_useConfMode = { packageSize: pkgSize, packageUnit: pkgUnit, totalSub, totalConf, subLabel };
// Show inventory info with sub-unit total
infoEl.innerHTML = `<strong>${t('use.available')}</strong> ` + items.map(i => {
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
const confQty = parseFloat(i.quantity);
const subQty = Math.round(confQty * pkgSize);
const confDisplay = confQty === Math.floor(confQty) ? Math.floor(confQty) : confQty.toFixed(1);
return `${loc.icon} ${loc.label}: ${confDisplay} conf (${subQty}${subLabel})`;
}).join(' · ');
// Show unit switch
unitSwitch.style.display = 'flex';
document.getElementById('use-unit-sub').textContent = subLabel;
// Default to sub-unit mode
switchUseUnit('sub');
// Trigger a live-box refresh with the latest reading if on scale
if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight);
} else {
// --- NORMAL MODE ---
_useConfMode = null;
_useNormalUnit = unit;
unitSwitch.style.display = 'none';
// Trigger a live-box refresh with the latest reading if on scale
if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight);
infoEl.innerHTML = `<strong>${t('use.available')}</strong> ` + items.map(i => {
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
const qLabel = formatQuantity(parseFloat(i.quantity), i.unit, i.default_quantity, i.package_unit);
return `${loc.icon} ${loc.label}: ${qLabel}`;
}).join(' · ');
const qtyInput = document.getElementById('use-quantity');
qtyInput.value = 1;
qtyInput.step = 'any';
qtyInput.min = '0.01';
document.getElementById('use-partial-hint').textContent = t('use.partial_hint');
// Fraction buttons for pz unit
const existingFrac = document.getElementById('pz-fraction-btns');
if (existingFrac) existingFrac.remove();
if (unit === 'pz') {
const fracDiv = document.createElement('div');
fracDiv.id = 'pz-fraction-btns';
fracDiv.className = 'pz-fraction-btns';
fracDiv.innerHTML = `
<p class="form-hint">${t('use.partial_piece_hint')}</p>
<div class="fraction-btn-row">
<button type="button" class="frac-btn" data-frac="0.25" onclick="setPzFraction(0.25)">¼ ${t('use.piece')}</button>
<button type="button" class="frac-btn" data-frac="0.5" onclick="setPzFraction(0.5)">½ ${t('use.piece')}</button>
<button type="button" class="frac-btn" data-frac="0.75" onclick="setPzFraction(0.75)">¾ ${t('use.piece')}</button>
<button type="button" class="frac-btn active" data-frac="1" onclick="setPzFraction(1)">${t('use.one_whole')}</button>
</div>`;
document.querySelector('#page-use .use-partial').appendChild(fracDiv);
}
}
} catch(e) {
console.error(e);
}
}
function switchUseUnit(mode) {
const subBtn = document.getElementById('use-unit-sub');
const confBtn = document.getElementById('use-unit-conf');
const qtyInput = document.getElementById('use-quantity');
const hint = document.getElementById('use-partial-hint');
if (mode === 'sub') {
subBtn.classList.add('active');
confBtn.classList.remove('active');
_useConfMode._activeUnit = 'sub';
const step = getSubUnitStep(_useConfMode.packageUnit);
qtyInput.value = step;
qtyInput.step = 'any';
qtyInput.min = 1;
hint.textContent = t('recipes.quantity_in_total', { unit: _useConfMode.subLabel, total: `${Math.round(_useConfMode.totalSub)}${_useConfMode.subLabel}` });
} else {
confBtn.classList.add('active');
subBtn.classList.remove('active');
_useConfMode._activeUnit = 'conf';
qtyInput.value = 1;
qtyInput.step = 'any';
qtyInput.min = 0.1;
hint.textContent = t('recipes.packs_of_have', { size: `${_useConfMode.packageSize}${_useConfMode.subLabel}`, count: _useConfMode.totalConf.toFixed(1) });
}
}
function getSubUnitStep(pkgUnit) {
switch (pkgUnit) {
case 'ml': return 50;
case 'g': return 10;
default: return 1;
}
}
function adjustUseQty(direction) {
_scaleUserDismissed = true;
_cancelScaleTimersOnly();
const input = document.getElementById('use-quantity');
let val = parseFloat(input.value) || 0;
let step;
if (_useConfMode && _useConfMode._activeUnit === 'sub') {
step = getSubUnitStep(_useConfMode.packageUnit);
} else if (_useConfMode && _useConfMode._activeUnit === 'conf') {
step = 0.5;
} else {
// Unit-aware step for normal mode
const u = _useNormalUnit || 'pz';
if (u === 'g' || u === 'ml') {
step = val < 50 ? 1 : (val < 500 ? 10 : 50);
} else {
step = 0.5; // pz: allow half-piece steps
}
}
val = Math.max(step, val + direction * step);
val = Math.round(val * 1000) / 1000;
// Cap at max available at selected location (in current unit)
const selectedLoc = document.getElementById('use-location')?.value;
if (selectedLoc && _useCurrentItems.length > 0) {
const locItems = _useCurrentItems.filter(i => i.location === selectedLoc);
const maxQtyAtLoc = locItems.reduce((s, i) => s + parseFloat(i.quantity || 0), 0);
if (maxQtyAtLoc > 0) {
// Convert to sub-unit for comparison if needed
const maxInCurrentUnit = (_useConfMode && _useConfMode._activeUnit === 'sub')
? maxQtyAtLoc * _useConfMode.packageSize
: maxQtyAtLoc;
val = Math.min(val, Math.round(maxInCurrentUnit * 1000) / 1000);
}
}
input.value = val;
// Sync fraction button highlight if visible
const newVal = parseFloat(input.value);
document.querySelectorAll('#pz-fraction-btns .frac-btn').forEach(b => {
b.classList.toggle('active', parseFloat(b.dataset.frac) === newVal);
});
}
function selectUseLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('use-location').value = loc;
}
// ── PREFERRED USE LOCATION ───────────────────────────────────────────────
// After 3+ consistent choices from the same location for a product,
// auto-selects it and hides the location picker (user can still tap "cambia").
const _PREF_LOC_KEY = '_prefUseLoc';
const _PREF_LOC_NEEDED = 3; // choices needed to confirm a preference
function _getPrefLocHistory(productId) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
return all[String(productId)] || [];
} catch { return []; }
}
function _recordUseLocationChoice(productId, loc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
const key = String(productId);
const hist = all[key] || [];
hist.push(loc);
if (hist.length > 8) hist.splice(0, hist.length - 8); // keep last 8
all[key] = hist;
localStorage.setItem(_PREF_LOC_KEY, JSON.stringify(all));
} catch { }
}
function _getPreferredUseLocation(productId) {
const hist = _getPrefLocHistory(productId);
if (hist.length < _PREF_LOC_NEEDED) return null;
const recent = hist.slice(-5); // look at last 5
const counts = {};
for (const loc of recent) counts[loc] = (counts[loc] || 0) + 1;
const [topLoc, topCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
return topCount >= _PREF_LOC_NEEDED ? topLoc : null;
}
function _expandUseLocationSelector() {
document.getElementById('pref-loc-info')?.style.setProperty('display', 'none');
document.getElementById('pref-loc-full')?.style.removeProperty('display');
}
// ────────────────────────────────────────────────────────────────────────
function setPzFraction(frac) {
document.getElementById('use-quantity').value = frac;
document.querySelectorAll('#pz-fraction-btns .frac-btn').forEach(b => {
b.classList.toggle('active', parseFloat(b.dataset.frac) === frac);
});
}
// ===== LOW STOCK → BRING! PROMPT =====
function isLowStock(totalRemaining, unit, defaultQty) {
if (totalRemaining <= 0) return true; // fully depleted → definitely needs restocking
if (unit === 'pz') return totalRemaining <= 1; // only 1 piece left
if (unit === 'conf') return totalRemaining < 1; // only warn when less than 1 full pack remains (opened/partial)
// Weight/volume: use percentage of default_qty or fixed threshold
if (defaultQty > 0) return totalRemaining <= defaultQty * 0.25;
// Fallback fixed thresholds
if (unit === 'g' || unit === 'ml') return totalRemaining <= 100;
return false;
}
/**
* Return the significant tokens of a product name for similarity matching.
* Strips stopwords and short tokens.
*/
function _nameTokens(name) {
const stop = new Set(['di','del','della','dei','degli','delle','da','in','con','per','su','a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo']);
return (name || '').toLowerCase()
.replace(/[^a-z\u00c0-\u024f\s]/gi, ' ')
.split(/\s+/)
.filter(t => t.length > 2 && !stop.has(t));
}
/**
* Check whether `name` matches any item in `list` (array of {name}).
* Returns the matching item or null.
* A match = at least one significant token in common.
* NOTE: intentionally loose — use _matchBringToSmart for display/urgency matching.
*/
function _findSimilarItem(name, list) {
const tokens = _nameTokens(name);
if (tokens.length === 0) return null;
return (list || []).find(item => {
const iTokens = _nameTokens(item.name || '');
return tokens.some(t => iTokens.includes(t));
}) || null;
}
/**
* Strict matching: find the smart item that corresponds to a Bring item by name.
* Rules (in order):
* 1. Exact case-insensitive match.
* 2. First significant token of both names must be identical
* ("Latte" → "Latte Parzialmente Scremato" ✓; "Frutta" ≠ "Muesli Frutta Secca" ✗).
* 3. For multi-token Bring names: all Bring tokens appear in the smart item tokens.
* This avoids false positives when a generic word ("frutta", "noci") appears as a
* secondary word inside an unrelated long product name.
*/
function _matchBringToSmart(bringName, smartItems) {
const bLower = bringName.toLowerCase();
const exact = smartItems.find(sd => sd.name.toLowerCase() === bLower);
if (exact) return exact;
const bTokens = _nameTokens(bringName);
if (bTokens.length === 0) return null;
const bFirst = bTokens[0];
// Rule 2: first token match
const firstMatch = smartItems.find(sd => {
const sdTokens = _nameTokens(sd.name);
return sdTokens.length > 0 && sdTokens[0] === bFirst;
});
if (firstMatch) return firstMatch;
// Rule 3: multi-token full subset
if (bTokens.length >= 2) {
const allMatch = smartItems.find(sd => {
const sdTokens = _nameTokens(sd.name);
return bTokens.every(t => sdTokens.includes(t));
});
if (allMatch) return allMatch;
}
return null;
}
function showLowStockBringPrompt(result, afterCallback) {
const name = result.product_name || currentProduct?.name || '';
// Generic shopping name (e.g. "Affettato" for "Mortadella IGP"). Falls back to
// the specific name when shopping_name is not set (older API call), so behaviour
// is unchanged for legacy callers.
const shoppingName = result.product_shopping_name || name;
const unit = result.product_unit || currentProduct?.unit || 'pz';
const defaultQty = result.product_default_qty || parseFloat(currentProduct?.default_quantity) || 0;
const totalRemaining = result.total_remaining;
// ── Fully depleted: no need to ask — backend already added to Bring! ──
// Skip the modal entirely and proceed to the next step (e.g. move modal).
if (totalRemaining <= 0) {
// Backend auto-adds to Bring! when fully depleted. If it failed (Bring not
// configured, or product already on list), silently attempt it from JS.
if (!result.added_to_bring && shoppingName) {
// Fire-and-forget — don't block the callback
// Use generic shopping name; specific name goes into specification.
const spec = shoppingName !== name ? name + (result.product_brand ? ` · ${result.product_brand}` : '') : '';
(async () => {
try {
const payload = { items: [{ name: shoppingName, specification: spec }] };
if (shoppingListUUID) payload.listUUID = shoppingListUUID;
const data = await api('bring_add', {}, 'POST', payload);
if (data.success && data.added > 0) {
showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info');
}
} catch(_e) { /* silent */ }
})();
}
if (afterCallback) afterCallback();
return;
}
if (!isLowStock(totalRemaining, unit, defaultQty)) {
if (afterCallback) afterCallback();
return;
}
// Format remaining for display
let remainLabel = '';
if (unit === 'conf' && result.product_package_unit) {
const subTotal = Math.round(totalRemaining * defaultQty);
remainLabel = `${subTotal}${result.product_package_unit}`;
} else {
const unitLabels = { pz: 'pz', g: 'g', ml: 'ml', conf: 'conf' };
remainLabel = `${Number.isInteger(totalRemaining) ? totalRemaining : totalRemaining.toFixed(1)} ${unitLabels[unit] || unit}`;
}
// --- Deduplication check ---
// 1. Already on Bring! list (shoppingItems)?
const alreadyOnBring = _findSimilarItem(shoppingName, shoppingItems) || _findSimilarItem(name, shoppingItems);
if (alreadyOnBring) {
// Already present (same or similar item). Just inform and continue.
showToast(t('shopping.already_in_list', { name: escapeHtml(alreadyOnBring.name) }), 'info');
if (afterCallback) afterCallback();
return;
}
// 2. In smart shopping predictions?
const smartMatch = _findSimilarItem(shoppingName, smartShoppingItems) || _findSimilarItem(name, smartShoppingItems);
const smartUrgencyLabel = {
critical: t('shopping.urgency_critical'), high: t('shopping.urgency_high'),
medium: t('shopping.urgency_medium'), low: t('shopping.urgency_low')
};
let smartNote = '';
if (smartMatch) {
const lbl = smartUrgencyLabel[smartMatch.urgency] || '';
const _smartMsg = t('shopping.smart_already_predicted').replace('{name}', escapeHtml(smartMatch.name)).replace('{urgency}', lbl ? ` (${lbl})` : '');
smartNote = `<div style="margin-bottom:12px;padding:8px 10px;background:rgba(249,115,22,0.1);border-radius:8px;border-left:3px solid #f97316;font-size:0.85rem">
${_smartMsg}
</div>`;
}
// _lowStockName = generic name that goes into Bring! (e.g. "Affettato")
// _lowStockSpec = specific product name used as specification (e.g. "Mortadella IGP")
window._lowStockAfterCallback = afterCallback;
window._lowStockName = shoppingName;
window._lowStockSpec = shoppingName !== name
? name + (result.product_brand ? ` · ${result.product_brand}` : '')
: name;
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>⚠️ Sta per finire!</h3>
<button class="modal-close" onclick="closeLowStockPrompt()">✕</button>
</div>
<div style="padding:0 16px 16px">
<p style="margin-bottom:12px">${t('lowstock.message').replace('{name}', `<strong>${escapeHtml(name)}</strong>`).replace('{qty}', `<strong>${remainLabel}</strong>`)}</p>
${smartNote}
<p style="margin-bottom:16px">${t('lowstock.question')}</p>
<button type="button" class="btn btn-large btn-success full-width" onclick="addLowStockToBring()">
${t('lowstock.yes')}
</button>
<button type="button" class="btn btn-secondary full-width" style="margin-top:8px" onclick="closeLowStockPrompt()">
${t('lowstock.no')}
</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
async function addLowStockToBring() {
closeModal();
try {
// Use the generic shopping name (e.g. "Affettato") set by showLowStockBringPrompt.
// _lowStockSpec holds the specific product name (e.g. "Mortadella IGP · Marca").
const bringName = window._lowStockName || '';
const spec = window._lowStockSpec || '';
window._lowStockName = null;
window._lowStockSpec = null;
const payload = { items: [{ name: bringName, specification: spec }] };
if (shoppingListUUID) payload.listUUID = shoppingListUUID;
const data = await api('bring_add', {}, 'POST', payload);
if (data.success && data.added > 0) {
showToast(t('shopping.added_to_bring').replace('{n}', data.added), 'success');
} else if (data.success && data.skipped > 0) {
showToast(t('shopping.already_in_list_short'), 'info');
}
} catch (e) {
showToast(t('error.bring_add'), 'error');
}
const cb = window._lowStockAfterCallback;
window._lowStockAfterCallback = null;
if (cb) cb();
}
function closeLowStockPrompt() {
closeModal();
const cb = window._lowStockAfterCallback;
window._lowStockAfterCallback = null;
if (cb) cb();
}
let _moveModalTimer = null;
let _moveModalRAF = null;
function clearMoveModalTimer() {
if (_moveModalTimer) { clearTimeout(_moveModalTimer); _moveModalTimer = null; }
if (_moveModalRAF) { cancelAnimationFrame(_moveModalRAF); _moveModalRAF = null; }
}
function startMoveModalCountdown(btnId, onExpire) {
clearMoveModalTimer();
const duration = 15000;
const start = performance.now();
const btn = document.getElementById(btnId);
if (!btn) return;
function tick() {
const elapsed = performance.now() - start;
const pct = Math.max(0, 100 - (elapsed / duration) * 100);
btn.style.background = `linear-gradient(to right, rgba(45,80,22,0.2) ${pct}%, transparent ${pct}%)`;
if (elapsed < duration) {
_moveModalRAF = requestAnimationFrame(tick);
}
}
_moveModalRAF = requestAnimationFrame(tick);
_moveModalTimer = setTimeout(() => {
clearMoveModalTimer();
onExpire();
}, duration);
}
function showMoveAfterUseModal(product, fromLoc, remaining, openedId) {
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
const locButtons = otherLocs.map(([k, v]) =>
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
).join('');
const wasVacuum = !!product.vacuum_sealed;
const vacuumRow = wasVacuum ? `
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
<input type="checkbox" id="move-vacuum-check" checked>
<span>${t('move.vacuum_restore')}</span>
</label>` : '';
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${t('move.title')}</h3>
<button class="modal-close" onclick="clearMoveModalTimer();closeModal();showPage('dashboard')">✕</button>
</div>
<div style="padding:0 16px 16px">
<p style="margin-bottom:12px">${t('move.question').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest')).replace('{name}', `<strong>${escapeHtml(product.name)}</strong>`)}</p>
<div class="location-selector">${locButtons}</div>
${vacuumRow}
<button type="button" id="btn-move-stay" class="btn btn-secondary full-width move-countdown-btn" style="margin-top:12px" onclick="clearMoveModalTimer();closeModal();showPage('dashboard')">${t('move.stay_btn').replace('{location}', LOCATIONS[fromLoc]?.label || fromLoc)}</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
startMoveModalCountdown('btn-move-stay', () => { closeModal(); showPage('dashboard'); });
}
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
clearMoveModalTimer();
const newVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
closeModal();
showLoading(true);
try {
if (openedId) {
// Move only the specific opened row — use opened shelf life
const product = { name: currentProduct?.name || '', category: currentProduct?.category || '' };
let days = estimateOpenedExpiryDays(product, toLoc);
await api('inventory_update', {}, 'POST', {
id: openedId,
location: toLoc,
expiry_date: addDays(days),
product_id: productId,
vacuum_sealed: newVacuum,
});
showToast(t('move.moved_toast').replace('{location}', LOCATIONS[toLoc]?.label || toLoc), 'success');
} else {
// Legacy: move whatever is at fromLoc
const data = await api('inventory_list');
const item = (data.inventory || []).find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0);
if (item) {
const product = { name: item.name || '', category: item.category || '' };
let days = estimateExpiryDays(product, toLoc);
if (newVacuum) days = getVacuumExpiryDays(days);
await api('inventory_update', {}, 'POST', {
id: item.id,
location: toLoc,
expiry_date: addDays(days),
product_id: productId,
vacuum_sealed: newVacuum,
});
showToast(`📦 Spostato in ${LOCATIONS[toLoc]?.label || toLoc}`, 'success');
}
}
} catch (e) {
console.error('Move error:', e);
}
showLoading(false);
showPage('dashboard');
}
async function submitUseAll() {
// Gate: show a countdown-confirmation before the destructive use_all call
const name = currentProduct ? currentProduct.name : '';
const items0 = _useCurrentItems ? _useCurrentItems.filter(i => parseFloat(i.quantity) > 0) : [];
const totalQty = items0.reduce((s, i) => s + parseFloat(i.quantity || 0), 0);
const unit = items0[0]?.unit || 'pz';
const qtyStr = formatQuantity(totalQty, unit, items0[0]?.default_quantity, items0[0]?.package_unit);
_showDestructiveConfirm(
t('use.use_all_confirm_title') || '✅ Finisci tutto',
`${t('use.use_all_confirm_msg') || 'Conferma che hai finito tutto il prodotto:'} "${name}" (${qtyStr})`,
_doSubmitUseAll,
t('use.use_all_confirm_btn') || '✅ Sì, finito'
);
}
async function _doSubmitUseAll() {
showLoading(true);
try {
const currentLoc = document.getElementById('use-location')?.value || '__all__';
const items = _useCurrentItems.filter(i => parseFloat(i.quantity) > 0);
const openedAtCurrentLoc = items.find(i => i.location === currentLoc && _isOpenedInventoryItem(i));
const allOpened = items.filter(_isOpenedInventoryItem);
let useLocation;
if (openedAtCurrentLoc) {
// Opened package at the currently selected location → finish only the opened item.
// The PHP backend fetches fractional (opened) rows first, so use_all on a specific
// location will clear the opened row and leave sealed packages untouched.
useLocation = currentLoc;
} else if (allOpened.length === 1) {
// One opened package somewhere else → almost certainly this is what the user means
useLocation = allOpened[0].location;
} else if (allOpened.length > 1) {
// Multiple opened packages at different locations → ask the user
showLoading(false);
_showUseAllDisambiguation(allOpened, items);
return;
} else {
// No opened packages anywhere → finish everything (original behaviour)
useLocation = '__all__';
}
const isOpenedFinish = useLocation !== '__all__' && items.some(
i => i.location === useLocation && _isOpenedInventoryItem(i)
);
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location: useLocation,
});
showLoading(false);
if (result.success) {
const toastMsg = isOpenedFinish
? `🔓 ${t('use.toast_opened_finished').replace('{name}', currentProduct.name)}`
: `📤 ${currentProduct.name} terminato!`;
showToast(toastMsg, 'success');
if (result.added_to_bring) {
setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500);
}
// Check low stock (product may exist at other locations)
showLowStockBringPrompt(result, () => showPage('dashboard'));
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
/**
* Show a modal asking which opened package to mark as finished.
* Called when multiple opened packages exist across different locations.
*/
function _showUseAllDisambiguation(openedItems, allItems) {
const contentEl = document.getElementById('modal-content');
const locButtons = openedItems.map(item => {
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const qtyStr = formatQuantity(parseFloat(item.quantity), item.unit, item.default_quantity, item.package_unit);
return `<button class="btn btn-warning full-width" style="justify-content:flex-start;gap:10px;text-align:left;margin-bottom:8px"
onclick="closeModal(); _submitUseAllAt('${item.location}', true)">
<span style="font-size:1.3rem">${locInfo.icon}</span>
<span><strong>${locInfo.label}</strong> — 🔓 ${t('use.opened_badge')}<br>
<small style="opacity:0.8">${qtyStr}</small></span>
</button>`;
}).join('');
// Option to finish everything
const totalQty = allItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
const unit = allItems[0]?.unit || 'pz';
const defaultQty = allItems[0]?.default_quantity;
const pkgUnit = allItems[0]?.package_unit;
const totalStr = formatQuantity(totalQty, unit, defaultQty, pkgUnit);
contentEl.innerHTML = `
<div class="modal-header">
<h3>${t('use.use_all')}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<p style="font-size:0.9rem;color:var(--text-muted);margin:0 0 14px">${t('use.disambiguation_hint')}</p>
${locButtons}
<button class="btn btn-danger full-width" style="margin-top:4px"
onclick="closeModal(); _submitUseAllAt('__all__', false)">
🗑️ ${t('use.disambiguation_all').replace('{qty}', totalStr)}
</button>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
async function _submitUseAllAt(location, isOpenedOnly) {
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location,
});
showLoading(false);
if (result.success) {
const toastMsg = isOpenedOnly
? `🔓 ${t('use.toast_opened_finished').replace('{name}', currentProduct.name)}`
: `📤 ${currentProduct.name} terminato!`;
showToast(toastMsg, 'success');
if (result.added_to_bring) {
setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500);
}
showLowStockBringPrompt(result, () => showPage('dashboard'));
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
async function submitUse(e) {
e.preventDefault();
if (_useSubmitting) return; // prevent double-submit from scale auto-confirm
_useSubmitting = true;
_cancelScaleTimersOnly();
_scaleStabilityVal = null;
showLoading(true);
try {
let qty = parseFloat(document.getElementById('use-quantity').value) || 1;
let displayQty = qty;
let displayUnit = '';
// Convert sub-unit to conf if needed
if (_useConfMode && _useConfMode._activeUnit === 'sub') {
displayUnit = _useConfMode.subLabel;
qty = qty / _useConfMode.packageSize; // convert to conf
} else if (_useConfMode && _useConfMode._activeUnit === 'conf') {
displayUnit = 'conf';
}
// ── Validate: cannot use more than available at selected location ─────────
const selectedLoc = document.getElementById('use-location').value;
const locItems = _useCurrentItems.filter(i => i.location === selectedLoc);
const maxQtyAtLoc = locItems.reduce((s, i) => s + parseFloat(i.quantity || 0), 0);
if (maxQtyAtLoc > 0 && qty > maxQtyAtLoc + 0.001) {
showLoading(false);
_useSubmitting = false;
showToast(t('use.error_exceeds_stock'), 'error');
// Shake the input to make it obvious
const inp = document.getElementById('use-quantity');
inp.classList.add('input-shake');
setTimeout(() => inp.classList.remove('input-shake'), 600);
return;
}
// ─────────────────────────────────────────────────────────────────────────
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
quantity: qty,
location: document.getElementById('use-location').value,
});
showLoading(false);
_useSubmitting = false;
if (result.success) {
const usedText = displayUnit ? `${displayQty}${displayUnit}` : displayQty;
showToast(t('use.toast_used').replace('{qty}', usedText).replace('{name}', currentProduct.name), 'success');
if (result.added_to_bring) {
setTimeout(() => showToast('🛒 Prodotto finito → aggiunto a Bring!', 'info'), 1500);
}
// If there's remaining quantity, offer to move to another location
const usedFrom = document.getElementById('use-location').value;
_recordUseLocationChoice(currentProduct.id, usedFrom); // track for preferred-location feature
const moveCallback = result.remaining > 0
? () => showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id)
: () => showPage('dashboard');
// Check low stock → Bring! prompt
showLowStockBringPrompt(result, moveCallback);
} else if (result.duplicate) {
// Silently ignore: this was a scale double-trigger, not a real error
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
_useSubmitting = false;
showToast(t('error.connection'), 'error');
}
}
// ===== AI IDENTIFICATION =====
async function captureForAI() {
if (!_requireGemini()) return;
stopScanner();
showPage('ai');
}
async function initAICamera() {
const video = document.getElementById('ai-video');
const captureDiv = document.getElementById('ai-capture');
const previewDiv = document.getElementById('ai-preview');
const captureBtn = document.getElementById('ai-capture-btn');
const retakeBtn = document.getElementById('ai-retake-btn');
const resultDiv = document.getElementById('ai-result');
captureDiv.style.display = 'block';
previewDiv.style.display = 'none';
captureBtn.style.display = 'block';
retakeBtn.style.display = 'none';
resultDiv.style.display = 'none';
try {
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
}
aiStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints());
video.srcObject = aiStream;
await video.play();
} catch (err) {
console.error('AI Camera error:', err);
showToast('Impossibile accedere alla fotocamera', 'error');
}
}
function takePhotoForAI() {
const video = document.getElementById('ai-video');
const canvas = document.getElementById('ai-canvas');
const img = document.getElementById('ai-image');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
img.src = dataUrl;
// Stop camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
aiStream = null;
}
video.srcObject = null;
document.getElementById('ai-capture').style.display = 'none';
document.getElementById('ai-preview').style.display = 'block';
document.getElementById('ai-capture-btn').style.display = 'none';
document.getElementById('ai-retake-btn').style.display = 'block';
// Immediately start analysis
analyzeWithAI();
}
function retakePhotoAI() {
document.getElementById('ai-result').style.display = 'none';
initAICamera();
}
async function analyzeWithAI() {
const resultDiv = document.getElementById('ai-result');
resultDiv.style.display = 'block';
resultDiv.innerHTML = `<div style="text-align:center;padding:20px"><div class="loading-spinner" style="margin:0 auto 12px"></div><p>${t('scanner.ai_identifying')}</p></div>`;
const canvas = document.getElementById('ai-canvas');
const base64 = canvas.toDataURL('image/jpeg', 0.7).split(',')[1];
try {
const result = await api('gemini_identify', {}, 'POST', { image: base64 });
if (!result.success) {
if (result.error === 'no_api_key') {
resultDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
} else if (/resource.?exhaust|quota|rate.?limit/i.test(result.error || '')) {
resultDiv.innerHTML = `<p style="color:var(--warning)">⏳ ${t('error.ai_quota')}</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">${t('btn.retry')}</button>`;
} else {
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ ${escapeHtml(result.error || t('error.identification'))}</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">${t('btn.retry')}</button>`;
}
return;
}
const id = result.identified;
const matches = result.off_matches || [];
// Search local DB for existing products that match the AI identification
let localMatches = [];
try {
const nameWords = (id.name || '').split(/\s+/).filter(w => w.length > 2);
const searches = [api('products_search', { q: id.name })];
if (id.brand) searches.push(api('products_search', { q: id.brand }));
const results = await Promise.all(searches);
const seen = new Set();
results.forEach(r => {
(r.products || []).forEach(p => {
if (!seen.has(p.id)) {
seen.add(p.id);
localMatches.push(p);
}
});
});
} catch(e) { /* ignore search errors */ }
let html = `<h4>🤖 Prodotto identificato</h4>`;
html += `<div class="ai-identified-card">`;
html += `<strong>${escapeHtml(id.name)}</strong>`;
if (id.brand) html += ` <span style="color:var(--text-muted)">- ${escapeHtml(id.brand)}</span>`;
if (id.description) html += `<p style="font-size:0.85rem;color:var(--text-light);margin:4px 0 0">${escapeHtml(id.description)}</p>`;
html += `</div>`;
// Show existing local products first
if (localMatches.length > 0) {
html += `<h4 style="margin-top:16px">${t('product.already_in_pantry')}</h4>`;
html += `<div class="ai-matches-list">`;
localMatches.forEach((p, idx) => {
html += `<div class="ai-match-item" onclick="selectLocalMatch(${p.id})">`;
if (p.image_url) {
html += `<img src="${escapeHtml(p.image_url)}" alt="" class="ai-match-img" onerror="this.style.display='none'">`;
}
html += `<div class="ai-match-info">`;
html += `<strong>${escapeHtml(p.name)}</strong>`;
if (p.brand) html += `<br><small>${escapeHtml(p.brand)}</small>`;
if (p.default_quantity && p.unit) html += `<br><small style="color:var(--text-muted)">${p.default_quantity} ${p.unit}</small>`;
html += `</div>`;
if (p.barcode) html += `<span class="ai-match-barcode">${p.barcode}</span>`;
html += `</div>`;
});
html += `</div>`;
}
if (matches.length > 0) {
html += `<h4 style="margin-top:16px">📦 Prodotti corrispondenti</h4>`;
html += `<div class="ai-matches-list">`;
matches.forEach((m, idx) => {
html += `<div class="ai-match-item" onclick="selectAIMatch(${idx})">`;
if (m.image_url) {
html += `<img src="${m.image_url}" alt="" class="ai-match-img" onerror="this.style.display='none'">`;
}
html += `<div class="ai-match-info">`;
html += `<strong>${escapeHtml(m.name)}</strong>`;
if (m.brand) html += `<br><small>${escapeHtml(m.brand)}</small>`;
if (m.quantity_info) html += `<br><small style="color:var(--text-muted)">${escapeHtml(m.quantity_info)}</small>`;
html += `</div>`;
html += `<span class="ai-match-barcode">${m.barcode}</span>`;
html += `</div>`;
});
html += `</div>`;
}
// Option to save as-is without barcode
html += `<div style="margin-top:16px; border-top: 1px solid var(--bg-light); padding-top: 12px">`;
html += `<button class="btn btn-secondary full-width" onclick="saveAIProductDirect()">🆕 Non è nessuno di questi — salva come nuovo</button>`;
html += `</div>`;
resultDiv.innerHTML = html;
// Store data for later use
window._aiIdentified = id;
window._aiMatches = matches;
} catch (err) {
console.error('AI identify error:', err);
resultDiv.innerHTML = `<p style="color:var(--danger)">❌ ${t('error.connection')}</p>
<button class="btn btn-secondary full-width mt-2" onclick="retakePhotoAI()">${t('btn.retry')}</button>`;
}
}
async function selectLocalMatch(productId) {
showLoading(true);
try {
const result = await api('product_get', { id: productId });
if (result.product) {
currentProduct = result.product;
showLoading(false);
showProductAction();
} else {
showLoading(false);
showToast(t('error.not_found'), 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
async function selectAIMatch(idx) {
const match = window._aiMatches[idx];
if (!match) return;
showLoading(true);
try {
// Use the barcode to do a full lookup (gets all details)
const localResult = await api('search_barcode', { barcode: match.barcode });
if (localResult.found) {
currentProduct = localResult.product;
showLoading(false);
showProductAction();
return;
}
// Full lookup via OpenFoodFacts
const lookupResult = await api('lookup_barcode', { barcode: match.barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
const detected = detectUnitAndQuantity(p.quantity_info);
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: notesParts.join(' · '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
weight_info: p.quantity_info || '',
};
showLoading(false);
showProductAction();
return;
}
}
// Fallback: save with basic info from match
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: match.name,
brand: match.brand || '',
category: match.category || '',
image_url: match.image_url || '',
unit: 'pz',
default_quantity: 1,
});
if (saveResult.id) {
currentProduct = { id: saveResult.id, barcode: match.barcode, name: match.name, brand: match.brand || '', category: match.category || '', image_url: match.image_url || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showProductAction();
} else {
showLoading(false);
showToast(t('error.save'), 'error');
}
} catch (err) {
showLoading(false);
console.error('AI match select error:', err);
showToast(t('error.connection'), 'error');
}
}
async function saveAIProductDirect() {
const id = window._aiIdentified;
if (!id) return;
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
name: id.name,
brand: id.brand || '',
category: id.category || '',
unit: 'pz',
default_quantity: 1,
});
if (result.success || result.id) {
currentProduct = { id: result.id, name: id.name, brand: id.brand || '', category: id.category || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || t('error.save'), 'error');
}
} catch (err) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
// ===== AI PHOTO FILL FOR PRODUCT FORM =====
let _pfAiStream = null;
async function captureForAIFormFill() {
if (!_requireGemini()) return;
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>📷 ${t('scan.ai_identify')}</h3>
<button class="modal-close" onclick="closePfAiScanner()">✕</button>
</div>
<div class="expiry-scanner">
<div id="pfai-cam-container" style="position:relative;border-radius:10px;overflow:hidden;background:#000;aspect-ratio:4/3">
<video id="pfai-video" autoplay playsinline style="width:100%;height:100%;object-fit:cover"></video>
<canvas id="pfai-canvas" style="display:none"></canvas>
<div style="position:absolute;inset:0;border:2px dashed rgba(255,255,255,0.4);border-radius:10px;pointer-events:none"></div>
</div>
<div id="pfai-preview-container" style="display:none;border-radius:10px;overflow:hidden;aspect-ratio:4/3">
<img id="pfai-preview-img" src="" alt="" style="width:100%;height:100%;object-fit:cover">
</div>
<div id="pfai-status" style="display:none;text-align:center;padding:12px">
<div class="loading-spinner" style="margin:0 auto 8px"></div>
<p>${t('scanner.ai_identifying')}</p>
</div>
<div id="pfai-result" style="display:none"></div>
<p class="form-hint" style="text-align:center;margin:6px 0;font-size:0.8rem" id="pfai-hint">${t('scanner.product_label_hint')}</p>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-large btn-accent" style="flex:1" id="pfai-capture-btn" onclick="pfAiCapture()">${t('scanner.capture_btn')}</button>
<button class="btn btn-large btn-secondary" style="flex:1;display:none" id="pfai-retake-btn" onclick="pfAiRetake()">${t('scanner.retake_btn')}</button>
</div>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
try {
_pfAiStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints());
const video = document.getElementById('pfai-video');
video.srcObject = _pfAiStream;
await video.play();
} catch (err) {
document.getElementById('pfai-cam-container').innerHTML =
`<p style="color:var(--danger);text-align:center;padding:20px">${t('error.camera')}</p>`;
}
}
function closePfAiScanner() {
if (_pfAiStream) { _pfAiStream.getTracks().forEach(t => t.stop()); _pfAiStream = null; }
closeModal();
}
function pfAiCapture() {
const video = document.getElementById('pfai-video');
const canvas = document.getElementById('pfai-canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
document.getElementById('pfai-preview-img').src = dataUrl;
if (_pfAiStream) { _pfAiStream.getTracks().forEach(t => t.stop()); _pfAiStream = null; }
video.srcObject = null;
document.getElementById('pfai-cam-container').style.display = 'none';
document.getElementById('pfai-preview-container').style.display = 'block';
document.getElementById('pfai-capture-btn').style.display = 'none';
document.getElementById('pfai-retake-btn').style.display = 'inline-flex';
document.getElementById('pfai-hint').style.display = 'none';
_pfAiAnalyze(canvas.toDataURL('image/jpeg', 0.7).split(',')[1]);
}
function pfAiRetake() {
document.getElementById('pfai-cam-container').style.display = 'block';
document.getElementById('pfai-preview-container').style.display = 'none';
document.getElementById('pfai-capture-btn').style.display = 'inline-flex';
document.getElementById('pfai-retake-btn').style.display = 'none';
document.getElementById('pfai-status').style.display = 'none';
document.getElementById('pfai-result').style.display = 'none';
document.getElementById('pfai-hint').style.display = 'block';
navigator.mediaDevices.getUserMedia(getCameraConstraints()).then(stream => {
_pfAiStream = stream;
const video = document.getElementById('pfai-video');
video.srcObject = stream;
video.play();
});
}
async function _pfAiAnalyze(base64) {
const statusEl = document.getElementById('pfai-status');
const resultEl = document.getElementById('pfai-result');
statusEl.style.display = 'block';
resultEl.style.display = 'none';
try {
const result = await api('gemini_identify', {}, 'POST', { image: base64 });
statusEl.style.display = 'none';
resultEl.style.display = 'block';
if (!result.success) {
if (/resource.?exhaust|quota|rate.?limit/i.test(result.error || '')) {
resultEl.innerHTML = `<p style="color:var(--warning);text-align:center">⏳ ${t('error.ai_quota')}</p>
<button class="btn btn-secondary full-width" onclick="pfAiRetake()">${t('btn.retry')}</button>`;
} else {
resultEl.innerHTML = `<p style="color:var(--danger);text-align:center">❌ ${escapeHtml(result.error || t('error.identification'))}</p>
<button class="btn btn-secondary full-width" onclick="pfAiRetake()">${t('btn.retry')}</button>`;
}
return;
}
const id = result.identified;
const matches = result.off_matches || [];
let html = `<div class="ai-identified-card" style="margin-bottom:10px">
<strong>${escapeHtml(id.name)}</strong>`;
if (id.brand) html += ` <span style="color:var(--text-muted)">— ${escapeHtml(id.brand)}</span>`;
if (id.description) html += `<p style="font-size:0.82rem;color:var(--text-light);margin:4px 0 0">${escapeHtml(id.description)}</p>`;
html += `</div>`;
if (matches.length > 0) {
html += `<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:6px">Seleziona la variante esatta o usa i dati AI:</p>`;
html += `<div class="ai-matches-list" style="max-height:160px;overflow-y:auto;margin-bottom:10px">`;
matches.forEach((m, idx) => {
html += `<div class="ai-match-item" onclick="_pfAiFillFromMatch(${idx})">`;
if (m.image_url) html += `<img src="${escapeHtml(m.image_url)}" alt="" class="ai-match-img" onerror="this.style.display='none'">`;
html += `<div class="ai-match-info"><strong>${escapeHtml(m.name)}</strong>`;
if (m.brand) html += `<br><small>${escapeHtml(m.brand)}</small>`;
if (m.quantity_info) html += `<br><small style="color:var(--text-muted)">${escapeHtml(m.quantity_info)}</small>`;
html += `</div><span class="ai-match-barcode">${escapeHtml(m.barcode)}</span></div>`;
});
html += `</div>`;
}
html += `<button class="btn btn-primary full-width" onclick="_pfAiFillFromAI()">${matches.length > 0 ? t('ai.use_data_no_barcode') : t('ai.use_data')}</button>`;
resultEl.innerHTML = html;
window._pfAiIdentified = id;
window._pfAiMatches = matches;
} catch (err) {
statusEl.style.display = 'none';
resultEl.style.display = 'block';
resultEl.innerHTML = `<p style="color:var(--danger);text-align:center">❌ ${t('error.connection')}</p>
<button class="btn btn-secondary full-width" onclick="pfAiRetake()">${t('btn.retry')}</button>`;
}
}
function _pfAiFillFields(name, brand, category, barcode, imageUrl, quantityInfo) {
if (name) document.getElementById('pf-name').value = name;
if (brand) document.getElementById('pf-brand').value = brand;
if (category) {
const cat = mapToLocalCategory(category, name || '');
document.getElementById('pf-category').value = cat;
document.getElementById('pf-category').dataset.manuallySet = 'true';
onCategoryChange(true);
}
if (barcode) document.getElementById('pf-barcode').value = barcode;
if (imageUrl) {
document.getElementById('pf-image').value = imageUrl;
const preview = document.getElementById('pf-image-preview');
document.getElementById('pf-image-img').src = imageUrl;
preview.style.display = 'block';
}
if (quantityInfo) {
const detected = detectUnitAndQuantity(quantityInfo);
document.getElementById('pf-unit').value = detected.unit;
document.getElementById('pf-defqty').value = detected.quantity;
document.getElementById('pf-defqty').dataset.manuallySet = 'true';
onPfUnitChange();
}
// Trigger auto-detect for remaining empty fields
if (name && !category) autoDetectCategory();
closePfAiScanner();
showToast('✅ Campi compilati dall\'AI', 'success');
}
function _pfAiFillFromAI() {
const id = window._pfAiIdentified;
if (!id) return;
_pfAiFillFields(id.name, id.brand, id.category, '', '', '');
}
async function _pfAiFillFromMatch(idx) {
const match = window._pfAiMatches[idx];
if (!match) return;
closePfAiScanner();
showLoading(true);
try {
const lookupResult = await api('lookup_barcode', { barcode: match.barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
_pfAiFillFields(p.name || match.name, p.brand || match.brand, p.category || '', match.barcode, p.image_url || match.image_url, p.quantity_info || '');
showLoading(false);
return;
}
} catch (e) {}
showLoading(false);
_pfAiFillFields(match.name, match.brand, match.category, match.barcode, match.image_url, '');
}
// ===== ALL PRODUCTS =====
async function loadAllProducts() {
try {
const data = await api('products_list');
renderProductsList(data.products || []);
} catch (err) {
console.error(err);
}
}
async function searchAllProducts() {
const q = document.getElementById('products-search').value;
if (q.length < 2) {
loadAllProducts();
return;
}
const data = await api('products_search', { q });
renderProductsList(data.products || []);
}
function renderProductsList(products) {
const container = document.getElementById('products-list');
if (products.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">📦</div><p>${t('inventory.empty_db')}</p></div>`;
return;
}
container.innerHTML = products.map(p => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(p.category, p.name)] || '📦';
return `
<div class="product-item" onclick="selectProductForAction(${p.id})">
<div class="inv-image">
${p.image_url ? `<img src="${escapeHtml(p.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(p.name)}</div>
${p.brand ? `<div class="inv-brand">${escapeHtml(p.brand)}</div>` : ''}
<div class="inv-meta">
${p.barcode ? `<span class="inv-badge" style="background:#f3f4f6;color:#374151">📊 ${p.barcode}</span>` : ''}
<span class="inv-badge" style="background:#f3f4f6;color:#374151">${catIcon} ${p.category || 'Non categorizzato'}</span>
</div>
</div>
</div>`;
}).join('');
}
async function selectProductForAction(productId) {
showLoading(true);
try {
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
showLoading(false);
// Clear search inputs after selecting a product
const psInput = document.getElementById('products-search');
if (psInput) psInput.value = '';
const invInput = document.getElementById('inventory-search');
if (invInput) invInput.value = '';
showProductAction();
} else {
showLoading(false);
showToast(t('error.not_found'), 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore', 'error');
}
}
// ===== SHOPPING LIST (BRING! INTEGRATION) =====
let shoppingListUUID = '';
let shoppingItems = [];
let suggestionItems = [];
let _spesaScanTarget = null; // { name, rawName, idx } when tapping item to scan
// ===== SHOPPING TABS =====
function switchShoppingTab(tab) {
document.querySelectorAll('.shopping-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel-shopping').forEach(p => p.classList.remove('active'));
document.getElementById(`tab-${tab}`)?.classList.add('active');
document.getElementById(`tab-panel-${tab}`)?.classList.add('active');
}
function updateShoppingTabCounts() {
const acquistoCount = shoppingItems.length;
const previsioneCount = smartShoppingItems.filter(i => !i.on_bring).length;
const acqEl = document.getElementById('tab-count-acquisto');
const prevEl = document.getElementById('tab-count-previsione');
if (acqEl) acqEl.textContent = acquistoCount;
if (prevEl) prevEl.textContent = previsioneCount;
document.getElementById('shopping-tabs')?.style.setProperty('display', 'flex');
}
// ===== LOCAL SHOPPING TAGS =====
function getShoppingTags(itemName) {
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
return tags[itemName.toLowerCase()] || [];
} catch { return []; }
}
function toggleShoppingTag(itemIdx, tag) {
const item = shoppingItems[itemIdx];
if (!item) return;
const key = item.name.toLowerCase();
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
const existing = tags[key] || [];
const pos = existing.indexOf(tag);
if (pos >= 0) existing.splice(pos, 1);
else existing.push(tag);
if (existing.length) tags[key] = existing;
else delete tags[key];
localStorage.setItem('shopping_tags', JSON.stringify(tags));
// Sync urgente/presto tag to Bring specification so it's visible in the Bring app
if (tag === 'urgente' && shoppingListUUID) {
const isNowUrgent = existing.includes('urgente');
const newSpec = isNowUrgent ? t('shopping.urgency_spec_critical') : '';
api('bring_add', {}, 'POST', {
items: [{ name: item.name, specification: newSpec, update_spec: true }],
listUUID: shoppingListUUID,
}).catch(() => {});
// Update local item spec for immediate re-render
item.specification = newSpec;
}
renderShoppingItems();
} catch (e) { console.error('toggleShoppingTag', e); }
}
// ===== SCAN FROM SHOPPING LIST =====
function openScanForItem(idx) {
const item = shoppingItems[idx];
if (!item) return;
_spesaScanTarget = { name: item.name, rawName: item.rawName || '', idx };
showPage('scan');
showToast(t('shopping.scan_toast').replace('{name}', item.name), 'info');
}
async function confirmShoppingItemFound() {
if (!_spesaScanTarget) return;
const { name, rawName } = _spesaScanTarget;
_spesaScanTarget = null;
document.getElementById('shopping-scan-target-banner').style.display = 'none';
try {
const r = await api('bring_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID });
if (r.success) {
const idx = shoppingItems.findIndex(i => i.name.toLowerCase() === name.toLowerCase());
if (idx >= 0) shoppingItems.splice(idx, 1);
showToast(t('shopping.item_removed').replace('{name}', name), 'success');
logOperation('bring_found', { name });
loadShoppingCount();
}
} catch (e) { console.error('confirmShoppingItemFound', e); }
showPage('shopping');
}
// ===== AUTO-ADD CRITICAL ITEMS TO BRING! =====
/** Build a Bring specification string that encodes urgency + optional brand. */
function _urgencyToSpec(urgency, brand) {
const urgencyLabels = { critical: t('shopping.urgency_spec_critical'), high: t('shopping.urgency_spec_high'), medium: '', low: '' };
const urgLabel = urgencyLabels[urgency] || '';
if (urgLabel && brand) return `${urgLabel} · ${brand}`;
if (urgLabel) return urgLabel;
return brand || '';
}
// ===== BRING! PURCHASED BLOCKLIST =====
// When an item disappears from Bring (user bought it), we block auto-re-add for 4h.
const _BRING_PURCHASED_TTL = 4 * 60 * 60 * 1000; // 4 hours
function _getBringPurchasedBlocklist() {
try {
const raw = localStorage.getItem('_bringPurchasedBlocklist');
const map = raw ? JSON.parse(raw) : {};
const now = Date.now();
// Prune expired entries
let changed = false;
for (const key of Object.keys(map)) {
if (now - map[key] > _BRING_PURCHASED_TTL) { delete map[key]; changed = true; }
}
if (changed) localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
return map;
} catch(e) { return {}; }
}
function _markBringPurchased(names) {
const map = _getBringPurchasedBlocklist();
const now = Date.now();
for (const n of names) map[n.toLowerCase()] = now;
localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
}
function _isBringPurchased(name, urgency) {
// Critical items: blocked only 30 min (enough to put groceries away).
// High: 90 min. Others: full 4 h.
const ttl = urgency === 'critical' ? 30 * 60 * 1000
: urgency === 'high' ? 90 * 60 * 1000
: _BRING_PURCHASED_TTL;
const map = _getBringPurchasedBlocklist();
const now = Date.now();
return Object.keys(map).some(k => {
const matches = _nameTokens(name)[0] === _nameTokens(k)[0] || k === name.toLowerCase();
if (!matches) return false;
return (now - map[k]) < ttl;
});
}
async function autoAddCriticalItems() {
// Time-based guard: run at most once every 5 minutes
const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0');
if (Date.now() - lastRun < 5 * 60 * 1000) return;
localStorage.setItem('_autoAddedCriticalTs', String(Date.now()));
// Auto-add rules:
// - critical: always
// - high: always (PHP already applies strict criteria for high urgency)
// - medium: when running out within 7 days (<1 week) for items used ≥3x/month
const toAdd = smartShoppingItems.filter(i => {
const imminentWeek = (i.days_left ?? 999) <= 7 && (i.uses_per_month || 0) >= 3;
if (i.on_bring) return false;
// For imminent items, do not honor local "purchased" blocklist too aggressively.
// If they are predicted to finish within a week, keep Bring aligned automatically.
// Bypass blocklist for depleted items (current_qty=0) — they ran out and must be re-added
if (!imminentWeek && (i.current_qty ?? 0) > 0 && _isBringPurchased(i.name, i.urgency)) return false;
if (i.urgency === 'critical') return true;
if (i.urgency === 'high') return true;
if (i.urgency === 'medium' && (i.days_left ?? 999) <= 7 && (i.uses_per_month || 0) >= 3) return true;
return false;
});
if (toAdd.length === 0) return;
const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) }));
try {
const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID });
if (result.success && result.added > 0) {
showToast(t('shopping.add_urgent_toast', { n: result.added }), 'success');
logOperation('bring_auto_add', { added: itemsToAdd.map(i => i.name) });
loadShoppingList();
}
} catch (e) { /* ignore */ }
}
/**
* Manually force a full Bring! sync: clears the purchased blocklist and all
* auto-add/cleanup timers, then re-adds all urgent items from scratch.
* Triggered by the user pressing "Forza sincronizzazione Bring!".
*/
async function forceSyncBring() {
const btn = document.getElementById('btn-force-sync');
if (btn) { btn.disabled = true; btn.textContent = `⏳ ${t('shopping.syncing')}`; }
// Clear all guards so the next run is unconditional
localStorage.removeItem('_bringPurchasedBlocklist');
localStorage.removeItem('_autoAddedCriticalTs');
localStorage.removeItem('_bringCleanupTs');
localStorage.removeItem('_userPinnedBring');
logOperation('force_sync_bring', {});
// Reload everything from scratch
await loadShoppingList();
if (btn) { btn.disabled = false; btn.textContent = `🔄 ${t('shopping.force_sync')}`; }
showToast(`🔄 ${t('shopping.sync_done')}`, 'success');
}
/**
* One-time cleanup: remove items from Bring! that were auto-added but the algorithm no
* longer considers relevant. CONSERVATIVE: only removes items that match a known product
* in our inventory with current_qty > 0 AND that no longer appear in smart predictions.
* Items not matching any DB product are left untouched (likely manually added by user).
*/
async function cleanupObsoleteBringItems() {
// Run at most once every 30 minutes
const lastCleanup = parseInt(localStorage.getItem('_bringCleanupTs') || '0');
if (Date.now() - lastCleanup < 30 * 60 * 1000) return;
localStorage.setItem('_bringCleanupTs', String(Date.now()));
if (!shoppingItems.length || !smartShoppingItems.length) return;
// Load live inventory (has actual quantities unlike products_list)
let invItems = [];
try {
const res = await api('inventory_list');
invItems = res.inventory || [];
} catch (e) { return; }
// Build: every significant token of in-stock products → total qty
// Any-token matching groups product families:
// 'Passata di pomodoro' + 'Polpa di pomodoro' share 'pomodoro' → same need
const stockByAnyToken = new Map();
for (const inv of invItems) {
const qty = parseFloat(inv.quantity || 0);
if (qty <= 0) continue;
for (const tok of _nameTokens(inv.name || '')) {
stockByAnyToken.set(tok, (stockByAnyToken.get(tok) || 0) + qty);
}
}
// Build: any matching token → smart item (any urgency — all predictions are protected)
const smartByToken = new Map();
for (const si of smartShoppingItems) {
for (const tok of _nameTokens(si.name)) {
if (!smartByToken.has(tok)) smartByToken.set(tok, si);
}
}
// User-pinned: items manually added via the suggestions panel — never auto-remove
let userPinned;
try {
const raw = localStorage.getItem('_userPinnedBring');
const map = raw ? JSON.parse(raw) : {};
// Prune entries older than 30 days
const now = Date.now();
let changed = false;
for (const k of Object.keys(map)) {
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
}
if (changed) localStorage.setItem('_userPinnedBring', JSON.stringify(map));
userPinned = map;
} catch(e) { userPinned = {}; }
const toRemove = [];
for (const item of shoppingItems) {
// Check if any significant token of this Bring item has stock in inventory
const itemTokens = _nameTokens(item.name);
const stockQty = itemTokens.reduce((sum, tok) => sum + (stockByAnyToken.get(tok) || 0), 0);
// No inventory stock for any related product → nothing to remove
if (stockQty <= 0) continue;
// Never remove items the user explicitly pinned from suggestions
if (userPinned[item.name.toLowerCase()]) continue;
// Check if smart shopping flags something with a matching token as needed (any urgency)
const smartSi = itemTokens.map(tok => smartByToken.get(tok)).find(Boolean);
if (smartSi) {
// Smart still predicts this item will be needed and it has remaining stock → keep it
if (smartSi.current_qty > 0) continue;
}
toRemove.push(item);
}
if (toRemove.length === 0) return;
let removed = 0;
const removedNames = [];
for (const item of toRemove) {
try {
const r = await api('bring_remove', {}, 'POST', {
name: item.name,
rawName: item.rawName || '',
listUUID: shoppingListUUID
});
if (r.success) { removed++; removedNames.push(item.name); }
} catch (e) { /* ignore individual failures */ }
}
if (removed > 0) {
showToast(t('shopping.removed_sufficient', { removed }), 'info');
logOperation('bring_cleanup', { removed: removedNames });
loadShoppingList();
}
}
/**
* Log an app operation (not a food transaction) for auditing/debugging.
* Stored in localStorage under '_opLog', capped at 200 entries.
*/
function logOperation(action, details) {
try {
const log = JSON.parse(localStorage.getItem('_opLog') || '[]');
log.push({ ts: new Date().toISOString(), action, details });
// Keep last 200 entries
if (log.length > 200) log.splice(0, log.length - 200);
localStorage.setItem('_opLog', JSON.stringify(log));
} catch (e) { /* ignore */ }
}
// Build a better search query from item name + specification
function buildSearchQuery(item) {
// Only use the item name for search - specification confuses the search engine
// The AI on the backend will use the specification to pick the right product
return item.name;
}
// Parse weight/quantity from specification (e.g. "200g" -> 0.2 kg, "500 ml" -> 0.5, "2 pz" -> 2 units)
function parseQtyFromSpec(spec) {
if (!spec) return null;
const s = spec.toLowerCase().trim();
// Match weight/volume: 200g, 0.5kg, 500 g, 1,5 kg, 200 gr
const m = s.match(/(\d+[.,]?\d*)\s*(g|gr|kg|ml|cl|l|lt)/i);
if (m) {
let val = parseFloat(m[1].replace(',', '.'));
const unit = m[2].toLowerCase();
if (unit === 'g' || unit === 'gr') return { kg: val / 1000, label: val + 'g', type: 'weight' };
if (unit === 'kg') return { kg: val, label: (val * 1000) + 'g', type: 'weight' };
if (unit === 'ml') return { kg: val / 1000, label: val + 'ml', type: 'weight' };
if (unit === 'cl') return { kg: val / 100, label: val * 10 + 'ml', type: 'weight' };
if (unit === 'l' || unit === 'lt') return { kg: val, label: (val * 1000) + 'ml', type: 'weight' };
}
// Match unit count: 2 pz, 3 pezzi, 5, 2x, ~5 pz
const pzMatch = s.match(/~?(\d+)\s*(pz|pezzi|x|$)/i);
if (pzMatch) {
const count = parseInt(pzMatch[1]);
if (count > 0 && count <= 50) return { count, label: count + ' pz', type: 'units' };
}
return null;
}
// Estimate price when product is sold per-kg/per-L or per-unit and user wants a certain quantity
function estimateItemPrice(product, spec) {
if (!product.priceUm) return null;
const umStr = String(product.priceUm);
const pm = umStr.match(/(\d+[.,]?\d*)/);
if (!pm) return null;
const pricePerUnit = parseFloat(pm[1].replace(',', '.'));
if (!pricePerUnit || pricePerUnit <= 0) return null;
const qty = parseQtyFromSpec(spec);
if (!qty) return null;
if (qty.type === 'weight') {
const estimated = pricePerUnit * qty.kg;
if (estimated <= 0 || estimated > 500) return null;
return { estimated: Math.round(estimated * 100) / 100, qtyLabel: qty.label };
} else if (qty.type === 'units') {
// For unit items: estimate per-item cost from the product price
// If product is per-kg and we want N pieces, estimate ~200-300g per piece
const avgWeightPerPiece = 0.25; // ~250g per piece (fruit/veg average)
const estimated = pricePerUnit * avgWeightPerPiece * qty.count;
if (estimated <= 0 || estimated > 500) return null;
return { estimated: Math.round(estimated * 100) / 100, qtyLabel: qty.label };
}
return null;
}
// ===== SMART SHOPPING =====
let smartShoppingItems = [];
let smartShoppingFilter = 'all';
let _smartShoppingLastFetch = 0; // timestamp of last successful fetch
let _bgShoppingInterval = null; // kept for compatibility, cron handles refresh server-side
/** Update dashboard badge from already-cached data */
function _updateSmartUrgencyBadge() {
const urgentEl = document.getElementById('stat-urgent');
if (!urgentEl) return;
const urgent = smartShoppingItems.filter(i => i.urgency === 'critical' || i.urgency === 'high').length;
if (urgent > 0) {
urgentEl.textContent = `⚠ ${urgent}`;
urgentEl.style.display = '';
} else {
urgentEl.style.display = 'none';
}
}
/**
* Sync the on_bring flag for every smartShoppingItem against the current shoppingItems list.
* The server cache can be up to 10 min old so on_bring may be stale — this corrects it
* client-side using strict first-token matching: a Bring item matches a smart item only when
* the first significant token of the Bring item's name equals the first significant token of
* the smart item's name (or exact name match). This avoids false positives like
* "Frutta" (fresh fruit on Bring) matching "Muesli Frutta Secca" (a different product).
*/
function _syncOnBringFlags() {
for (const si of smartShoppingItems) {
const siLower = si.name.toLowerCase();
const siFirst = _nameTokens(si.name)[0];
const siShoppingLower = (si.shopping_name || '').toLowerCase();
const siShoppingFirst = si.shopping_name ? _nameTokens(si.shopping_name)[0] : null;
si.on_bring = !!(
shoppingItems.find(bi => bi.name.toLowerCase() === siLower) ||
(siShoppingLower && shoppingItems.find(bi => bi.name.toLowerCase() === siShoppingLower)) ||
(siFirst && shoppingItems.find(bi => _nameTokens(bi.name)[0] === siFirst)) ||
(siShoppingFirst && shoppingItems.find(bi => _nameTokens(bi.name)[0] === siShoppingFirst))
);
}
}
function _renderSmartLastUpdate() {
const el = document.getElementById('smart-last-update');
if (!el || !_smartShoppingLastFetch) return;
const d = new Date(_smartShoppingLastFetch);
el.textContent = `Aggiornato ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
function startBgShoppingRefresh() {
// No-op: server-side cron handles refresh every 5 minutes.
// The JS fetches pre-computed cache on demand (instant response).
}
async function loadSmartShopping() {
try {
const data = await api('smart_shopping');
if (data.success && data.items && data.items.length > 0) {
const prevCriticalNames = new Set(
smartShoppingItems.filter(i => i.urgency === 'critical').map(i => i.name)
);
smartShoppingItems = data.items;
_smartShoppingLastFetch = Date.now();
// If the set of critical items changed, reset autoAdd/cleanup timers so
// they run with fresh data on next shopping page load
const newCriticalNames = new Set(data.items.filter(i => i.urgency === 'critical').map(i => i.name));
const criticalChanged = [...prevCriticalNames].some(n => !newCriticalNames.has(n)) ||
[...newCriticalNames].some(n => !prevCriticalNames.has(n));
if (criticalChanged) {
localStorage.removeItem('_autoAddedCriticalTs');
localStorage.removeItem('_bringCleanupTs');
}
renderSmartShopping();
_renderSmartLastUpdate();
_updateSmartUrgencyBadge();
document.getElementById('smart-shopping-empty').style.display = 'none';
document.getElementById('smart-shopping-content').style.display = 'block';
} else {
smartShoppingItems = [];
_smartShoppingLastFetch = Date.now();
document.getElementById('smart-shopping-empty').style.display = 'block';
document.getElementById('smart-shopping-content').style.display = 'none';
}
} catch (e) {
console.error('Smart shopping error:', e);
smartShoppingItems = [];
}
updateShoppingTabCounts();
}
function filterSmart(filter) {
smartShoppingFilter = filter;
document.querySelectorAll('.smart-filter').forEach(b => b.classList.remove('active'));
document.querySelector(`.smart-filter[data-filter="${filter}"]`)?.classList.add('active');
renderSmartShopping();
}
function renderSmartShopping() {
const container = document.getElementById('smart-items');
const countEl = document.getElementById('smart-count');
const actionsEl = document.getElementById('smart-actions');
let items = smartShoppingItems;
if (smartShoppingFilter !== 'all') {
items = items.filter(i => i.urgency === smartShoppingFilter);
}
countEl.textContent = items.length;
if (items.length === 0) {
container.innerHTML = `<div class="empty-state" style="padding:16px"><p>${t('shopping.empty_category')}</p></div>`;
actionsEl.style.display = 'none';
return;
}
const urgencyConfig = {
critical: { color: '#ef4444', bg: 'rgba(239,68,68,0.08)', icon: '🔴', label: t('shopping.urgency_critical') },
high: { color: '#f97316', bg: 'rgba(249,115,22,0.08)', icon: '🟠', label: t('shopping.urgency_high') },
medium: { color: '#eab308', bg: 'rgba(234,179,8,0.08)', icon: '🟡', label: t('shopping.urgency_medium') },
low: { color: '#22c55e', bg: 'rgba(34,197,94,0.08)', icon: '🟢', label: t('shopping.urgency_low') },
};
// Group by section
const smartSectionMap = new Map();
items.forEach(item => {
const sec = getItemSection(item.name);
if (!smartSectionMap.has(sec.key)) smartSectionMap.set(sec.key, { sec, items: [] });
smartSectionMap.get(sec.key).items.push(item);
});
let smartHtml = '';
for (const secDef of SHOPPING_SECTIONS) {
const group = smartSectionMap.get(secDef.key);
if (!group) continue;
smartHtml += `<div class="shopping-section-divider"><span class="sec-icon">${secDef.icon}</span>${secDef.label}</div>`;
for (const item of group.items) {
smartHtml += renderSmartItem(item, items);
}
}
container.innerHTML = smartHtml;
// Show/hide add button based on checkable items
const hasCheckable = items.some(i => !i.on_bring);
actionsEl.style.display = hasCheckable ? 'block' : 'none';
}
function renderSmartItem(item) {
const urgencyConfig = {
critical: { color: '#ef4444', bg: 'rgba(239,68,68,0.08)', icon: '🔴', label: t('shopping.urgency_critical') },
high: { color: '#f97316', bg: 'rgba(249,115,22,0.08)', icon: '🟠', label: t('shopping.urgency_high') },
medium: { color: '#eab308', bg: 'rgba(234,179,8,0.08)', icon: '🟡', label: t('shopping.urgency_medium') },
low: { color: '#22c55e', bg: 'rgba(34,197,94,0.08)', icon: '🟢', label: t('shopping.urgency_low') },
};
const u = urgencyConfig[item.urgency] || urgencyConfig.low;
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
const globalIdx = smartShoppingItems.indexOf(item);
// Generic vs specific name logic
const shoppingName = item.shopping_name || item.name;
const isGeneric = shoppingName !== item.name;
const variants = item.variants || [];
// Build title line: generic name (and brand only if not grouped)
let nameLine = `<div class="smart-item-name">${escapeHtml(shoppingName)}`;
if (!isGeneric && item.brand) nameLine += ` <small class="smart-brand">${escapeHtml(item.brand)}</small>`;
nameLine += `</div>`;
// Build subtitle: specific product + brand when grouped, plus any variants
let specificLine = '';
if (isGeneric || variants.length > 0) {
let specifics = [];
specifics.push(item.name + (item.brand ? ` (${item.brand})` : ''));
for (const v of variants) {
specifics.push(v.name + (v.brand ? ` (${v.brand})` : ''));
}
specificLine = `<div class="smart-item-specific">${escapeHtml(specifics.join(' · '))}</div>`;
}
// Stock bar
const pct = Math.min(100, Math.max(0, item.pct_left));
const barColor = pct <= 15 ? '#ef4444' : pct <= 30 ? '#f97316' : pct <= 50 ? '#eab308' : '#22c55e';
// Quantity display
let qtyText = '';
if (item.current_qty > 0) {
qtyText = `${item.current_qty} ${item.unit}`;
if (item.pct_left < 100) qtyText += ` (${pct}%)`;
} else {
qtyText = t('shopping.out_of_stock');
}
// Usage frequency badge
let freqBadge = '';
if (item.use_count >= 8) freqBadge = `<span class="smart-freq-badge freq-high">${t('shopping.freq_high')}</span>`;
else if (item.use_count >= 4) freqBadge = `<span class="smart-freq-badge freq-med">${t('shopping.freq_regular')}</span>`;
else if (item.use_count >= 2) freqBadge = `<span class="smart-freq-badge freq-low">${t('shopping.freq_occasional')}</span>`;
// Days left prediction
let predBadge = '';
if (item.days_left <= 3 && item.days_left > 0 && item.current_qty > 0) {
predBadge = `<span class="smart-pred-badge pred-urgent">${t('expiry.badge_days_left').replace('{n}', item.days_left)}</span>`;
} else if (item.days_left <= 7 && item.days_left > 0 && item.current_qty > 0) {
predBadge = `<span class="smart-pred-badge pred-soon">${t('expiry.badge_days_left').replace('{n}', item.days_left)}</span>`;
}
// Expiry badge
let expiryBadge = '';
if (item.days_to_expiry < 0 && item.current_qty > 0) {
expiryBadge = `<span class="smart-pred-badge pred-urgent">${t('expiry.badge_expired_bare')}</span>`;
} else if (item.days_to_expiry <= 3 && item.days_to_expiry >= 0 && item.current_qty > 0) {
expiryBadge = `<span class="smart-pred-badge pred-urgent">${t('expiry.badge_expires_warn').replace('{n}', item.days_to_expiry)}</span>`;
}
return `
<div class="smart-item" style="border-left: 3px solid ${u.color}; background: ${u.bg}">
<div class="smart-item-top">
${!item.on_bring ? `<input type="checkbox" class="smart-check" data-idx="${globalIdx}">` : ''}
<span class="smart-item-icon">${catIcon}</span>
<div class="smart-item-info">
${nameLine}
${specificLine}
<div class="smart-item-reasons">${item.reasons.map(r => `<span>${escapeHtml(r)}</span>`).join(' · ')}</div>
<div class="smart-item-badges">
<span class="smart-urgency-badge" style="color:${u.color}">${u.icon} ${u.label}</span>
${freqBadge}${predBadge}${expiryBadge}
${item.is_opened ? `<span class="smart-freq-badge freq-low">${t('inventory.opened_badge')}</span>` : ''}
${item.on_bring ? `<span class="smart-bring-badge">${t('shopping.bring_badge')}</span>` : ''}
</div>
</div>
<div class="smart-item-stock">
<span class="smart-qty">${qtyText}</span>
${item.current_qty > 0 ? `<div class="smart-stock-bar"><div class="smart-stock-fill" style="width:${pct}%;background:${barColor}"></div></div>` : ''}
</div>
</div>
</div>`;
}
async function migrateBringNames(btn) {
const statusEl = document.getElementById('bring-migrate-status');
if (btn) btn.disabled = true;
if (statusEl) { statusEl.style.display = 'inline'; statusEl.textContent = '⏳ In corso…'; }
try {
const data = await api('bring_migrate_names', {}, 'POST', {});
if (data.success) {
const msg = t('shopping.migration_done', { migrated: data.migrated, skipped: data.skipped }) + (data.errors ? `, ${data.errors} errori` : '');
if (statusEl) statusEl.textContent = msg;
if (data.migrated > 0) {
showToast(`🔄 ${data.migrated} nomi generalizzati in Bring!`, 'success');
loadShoppingList(); // refresh the shopping list view
} else {
showToast('Tutti i nomi sono già aggiornati', 'info');
}
} else {
if (statusEl) statusEl.textContent = '❌ ' + (data.error || 'Errore');
}
} catch(e) {
if (statusEl) statusEl.textContent = '❌ Errore di connessione';
}
if (btn) btn.disabled = false;
}
async function addSmartToBring() {
const checks = document.querySelectorAll('.smart-check:checked');
if (checks.length === 0) {
showToast(t('error.select_items'), 'info');
return;
}
const itemsToAdd = [];
checks.forEach(cb => {
const idx = parseInt(cb.dataset.idx);
const item = smartShoppingItems[idx];
if (item) {
const shoppingName = item.shopping_name || item.name;
const isGeneric = shoppingName !== item.name;
// When generic, use specific product name + brand as the specification
const spec = isGeneric
? (item.name + (item.brand ? ` · ${item.brand}` : ''))
: _urgencyToSpec(item.urgency, item.brand);
itemsToAdd.push({
name: shoppingName,
specification: spec,
});
}
});
showLoading(true);
try {
const result = await api('bring_add', {}, 'POST', {
items: itemsToAdd,
listUUID: shoppingListUUID,
});
showLoading(false);
if (result.success) {
const msg = result.added > 0
? t('shopping.added_to_bring', { n: result.added }) + (result.skipped > 0 ? ` (${t('shopping.added_to_bring_skip', { n: result.skipped })})` : '')
: t('shopping.all_on_bring');
showToast(msg, result.added > 0 ? 'success' : 'info');
// Mark all manually-added items as user-pinned so cleanupObsoleteBringItems never removes them
if (result.added > 0) {
const pinned = JSON.parse(localStorage.getItem('_userPinnedBring') || '{}');
const now = Date.now();
for (const it of itemsToAdd) pinned[it.name.toLowerCase()] = now;
localStorage.setItem('_userPinnedBring', JSON.stringify(pinned));
}
// Reload to refresh badges
loadShoppingList();
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
// Load just the shopping count for dashboard stat card
async function loadShoppingCount() {
try {
const data = await api('bring_list');
const el = document.getElementById('stat-spesa');
if (data.success && data.purchase) {
el.textContent = data.purchase.length;
} else {
el.textContent = '-';
}
el.classList.remove('stat-loading');
} catch {
const el = document.getElementById('stat-spesa');
el.textContent = '-';
el.classList.remove('stat-loading');
}
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
_updateSmartUrgencyBadge();
} else {
try {
const smart = await api('smart_shopping');
if (smart.success && smart.items) {
smartShoppingItems = smart.items;
_smartShoppingLastFetch = Date.now();
_updateSmartUrgencyBadge();
}
} catch { /* ignore */ }
}
}
/**
* Sync local 'urgente' tag from Bring specification.
* If a Bring item's specification contains 'urgente', ensure the local tag is set.
* If a Bring item's specification is empty/cleared, remove the local urgente tag
* UNLESS smart shopping considers it critical (to avoid losing urgency on stale specs).
*/
function _syncTagsFromBringSpec() {
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
let changed = false;
for (const item of shoppingItems) {
const key = item.name.toLowerCase();
const spec = (item.specification || '').toLowerCase();
const existing = tags[key] || [];
const hasUrgente = existing.includes('urgente');
const smartMatch = _matchBringToSmart(item.name, smartShoppingItems);
const smartIsCritical = smartMatch && (smartMatch.urgency === 'critical' || smartMatch.urgency === 'high');
if ((spec.includes('urgente') || spec.includes('presto') || smartIsCritical) && !hasUrgente) {
existing.push('urgente');
tags[key] = existing;
changed = true;
} else if (!spec.includes('urgente') && !spec.includes('presto') && !smartIsCritical && hasUrgente) {
existing.splice(existing.indexOf('urgente'), 1);
if (existing.length) tags[key] = existing;
else delete tags[key];
changed = true;
}
}
if (changed) localStorage.setItem('shopping_tags', JSON.stringify(tags));
} catch (e) { /* ignore */ }
}
/**
* After smart shopping loads, push urgency specifications to Bring for all matched items.
* This makes urgency visible in the native Bring app via the item specification field.
* Only updates if the spec has changed (to avoid unnecessary API calls).
*/
async function autoSyncUrgencySpecs() {
if (!shoppingListUUID || !smartShoppingItems.length) return;
const toUpdate = [];
for (const item of shoppingItems) {
const smartMatch = _matchBringToSmart(item.name, smartShoppingItems);
if (!smartMatch) continue;
const expectedSpec = _urgencyToSpec(smartMatch.urgency, '');
const currentSpec = (item.specification || '').toLowerCase();
// Only update if urgency marker changed (don't clobber user-set spec info that isn't urgency)
const currentHasUrgencyMarker = currentSpec.includes('urgente') || currentSpec.includes('presto');
const needsUpdate = expectedSpec && !currentHasUrgencyMarker;
const needsClear = !expectedSpec && currentHasUrgencyMarker;
// Also update if urgency level changed (e.g. medium→high or high→critical)
const currentIsHigh = currentSpec.includes('urgente');
const newIsHigh = (expectedSpec || '').toLowerCase().includes('urgente');
const urgencyEscalated = expectedSpec && currentHasUrgencyMarker && (currentIsHigh !== newIsHigh);
if (needsUpdate || needsClear || urgencyEscalated) {
toUpdate.push({ name: item.name, specification: expectedSpec, update_spec: true });
// Optimistically update local item so re-render is immediate
item.specification = expectedSpec;
}
}
if (toUpdate.length === 0) return;
try {
await api('bring_add', {}, 'POST', { items: toUpdate, listUUID: shoppingListUUID });
} catch (e) { /* ignore - sync is best-effort */ }
}
async function loadShoppingList() {
const statusEl = document.getElementById('bring-status');
const currentEl = document.getElementById('shopping-current');
const suggestionsEl = document.getElementById('shopping-suggestions');
statusEl.style.display = 'block';
statusEl.innerHTML = `<div class="bring-loading"><div class="loading-spinner"></div> ${t('shopping.bring_loading')}</div>`;
currentEl.style.display = 'none';
suggestionsEl.style.display = 'none';
// ── Demo mode: show placeholder list, skip all Bring! API calls ──────────
if (_demoMode) {
statusEl.style.display = 'none';
shoppingListUUID = 'demo-list';
shoppingItems = [
{ name: 'Latte', specification: '🟠 presto · 1L', rawName: 'Latte' },
{ name: 'Pane', specification: '', rawName: 'Pane' },
{ name: 'Uova', specification: '⚡ urgente', rawName: 'Uova' },
{ name: 'Pasta', specification: '500g', rawName: 'Pasta' },
{ name: 'Pomodori', specification: '1kg', rawName: 'Pomodori' },
];
renderShoppingItems();
currentEl.style.display = 'block';
loadSmartShopping().then(() => {
_syncOnBringFlags();
renderSmartShopping();
updateShoppingTabCounts();
renderShoppingItems();
});
return;
}
try {
const data = await api('bring_list');
statusEl.style.display = 'none';
if (!data.success) {
statusEl.style.display = 'block';
const isMissingCreds = data.error && data.error.toLowerCase().includes('credenziali bring');
if (isMissingCreds) {
statusEl.innerHTML = `<div class="bring-error">🔑 ${t('shopping.bring_not_configured') || 'Bring! non è configurato. Aggiungi email e password nelle <a href="#" onclick="showPage(\'settings\');return false;">impostazioni</a>.'}</div>`;
} else {
statusEl.innerHTML = `<div class="bring-error">⚠️ ${escapeHtml(data.error || t('error.bring_connection'))}</div>`;
}
return;
}
shoppingListUUID = data.listUUID;
// Detect items removed from Bring since last load (= just purchased by user)
const prevNames = new Set((shoppingItems || []).map(i => i.name.toLowerCase()));
const newItems = data.purchase || [];
const newNames = new Set(newItems.map(i => i.name.toLowerCase()));
if (prevNames.size > 0) {
const removedNames = [...prevNames].filter(n => !newNames.has(n));
if (removedNames.length) _markBringPurchased(removedNames);
}
shoppingItems = newItems;
// Sync urgente local tags from Bring specification (items marked urgent by us or manually)
_syncTagsFromBringSpec();
renderShoppingItems();
currentEl.style.display = 'block';
// Load smart shopping predictions, then re-render to show badges + auto-add critical
loadSmartShopping().then(() => {
_syncOnBringFlags(); // sync on_bring against current Bring list before any logic reads it
_syncTagsFromBringSpec(); // re-sync tags now that smart data is available
autoSyncUrgencySpecs(); // push urgency specs to Bring for matched items
renderSmartShopping(); // re-render smart tab with corrected on_bring flags
updateShoppingTabCounts(); // update tab badges with corrected counts
autoAddCriticalItems();
cleanupObsoleteBringItems();
renderShoppingItems(); // re-render shopping tab with urgency badges
});
} catch (err) {
console.error('Bring! error:', err);
statusEl.style.display = 'block';
statusEl.innerHTML = `<div class="bring-error">${t('error.bring_connection')}</div>`;
}
}
/** Return the spec text to show in the UI, stripping urgency markers (those are shown as badges). */
function _specDisplayText(spec) {
if (!spec) return '';
// Strip known urgency prefixes set by _urgencyToSpec (case-insensitive, then trim separator)
const lower = spec.toLowerCase();
for (const prefix of ['⚡ urgente', '🟠 presto']) {
if (lower.startsWith(prefix)) {
return spec.slice(prefix.length).replace(/^\s*[·\-]\s*/, '').trim();
}
}
return spec;
}
/** Return the spec for price search, stripping urgency markers that would confuse the AI. */
function _cleanSpecForSearch(spec) {
return _specDisplayText(spec);
}
async function renderShoppingItems() {
const container = document.getElementById('shopping-items');
const countEl = document.getElementById('shopping-count');
countEl.textContent = shoppingItems.length;
// Update tab count too
const tabCount = document.getElementById('tab-count-acquisto');
if (tabCount) tabCount.textContent = shoppingItems.length;
if (shoppingItems.length === 0) {
container.innerHTML = `<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>${t('shopping.empty')}</p></div>`;
return;
}
const s = getSettings();
// Build section groups, sorted by urgency weight within each section
const TAG_LABELS = { urgente: t('shopping.tag_urgent'), prio: t('shopping.tag_priority'), check: t('shopping.tag_check') };
const urgencyMap = {
critical: { icon: '🔴', label: t('shopping.urgency_critical'), cls: 'badge-critical' },
high: { icon: '🟠', label: t('shopping.urgency_high'), cls: 'badge-high' },
medium: { icon: '🟡', label: t('shopping.urgency_medium_short'), cls: 'badge-medium' },
low: { icon: '🟢', label: t('shopping.urgency_low_short'), cls: 'badge-low' },
};
// Map each item to its section + urgency (strict first-token matching to avoid false positives)
// Also derive urgency from Bring specification if smart matching fails
const enriched = shoppingItems.map((item, idx) => {
const smartData = _matchBringToSmart(item.name, smartShoppingItems);
let urgency = smartData?.urgency || null;
// Fallback: read urgency from Bring specification (set by our app when adding)
if (!urgency && item.specification) {
const spec = item.specification.toLowerCase();
if (spec.includes('urgente')) urgency = 'critical';
else if (spec.includes('presto')) urgency = 'high';
}
const sec = getItemSection(item.name);
return { item, idx, smartData, urgency, sec };
});
// Group by section key, preserving SHOPPING_SECTIONS order
const sectionMap = new Map();
for (const e of enriched) {
const key = e.sec.key;
if (!sectionMap.has(key)) sectionMap.set(key, { sec: e.sec, items: [] });
sectionMap.get(key).items.push(e);
}
// Sort items within each section: by urgency weight desc, then by use_count desc
for (const [, group] of sectionMap) {
group.items.sort((a, b) => {
const wa = URGENCY_WEIGHT[a.urgency] || 0;
const wb = URGENCY_WEIGHT[b.urgency] || 0;
if (wb !== wa) return wb - wa;
return (b.smartData?.use_count || 0) - (a.smartData?.use_count || 0);
});
}
// Render sections in canonical order
let html = '';
for (const secDef of SHOPPING_SECTIONS) {
const group = sectionMap.get(secDef.key);
if (!group) continue;
html += `<div class="shopping-section-divider"><span class="sec-icon">${secDef.icon}</span>${secDef.label}</div>`;
for (const { item, idx, smartData, urgency } of group.items) {
const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || '🛒';
const bgStyle = urgency && URGENCY_BG[urgency] ? ` style="background:${URGENCY_BG[urgency]}"` : '';
const localTags = getShoppingTags(item.name);
// Urgency badge
let urgencyBadge = '';
if (urgency && urgencyMap[urgency]) {
const u = urgencyMap[urgency];
urgencyBadge = `<span class="sinv-badge ${u.cls}">${u.icon} ${u.label}</span>`;
}
// Frequency badge
let freqBadge = '';
if (smartData && smartData.use_count >= 8) freqBadge = `<span class="sinv-badge badge-freq-high">📈 ${smartData.use_count}x</span>`;
else if (smartData && smartData.use_count >= 4) freqBadge = `<span class="sinv-badge badge-freq-med">📊 ${smartData.use_count}x</span>`;
else if (smartData && smartData.use_count >= 2) freqBadge = `<span class="sinv-badge badge-freq-low">📉 ${smartData.use_count}x</span>`;
const localTagHtml = localTags.map(t =>
`<span class="sinv-badge badge-local-tag" onclick="event.stopPropagation(); toggleShoppingTag(${idx}, '${t}')">${TAG_LABELS[t] || t} ✕</span>`
).join('');
const tagMenu = `<div class="shopping-tag-menu" onclick="event.stopPropagation()">
${Object.entries(TAG_LABELS).map(([k, v]) =>
`<button class="sinv-badge badge-tag-add ${localTags.includes(k) ? 'active' : ''}" onclick="toggleShoppingTag(${idx}, '${k}')">${v}</button>`
).join('')}
</div>`;
html += `
<div class="shopping-item" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="${t('shopping.tap_to_scan')}"${bgStyle}>
<span class="shopping-item-icon">${catIcon}</span>
<div class="shopping-item-body">
<div class="shopping-item-top">
<div class="shopping-item-info">
<div class="shopping-item-name-row">
<span class="shopping-item-name">${escapeHtml(item.name)}</span>
<span class="shopping-item-scan-hint">📷</span>
</div>
${_specDisplayText(item.specification) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''}
${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''}
</div>
<div class="shopping-item-right" onclick="event.stopPropagation()">
<button class="shopping-item-tag-btn" onclick="toggleShoppingTagMenu(this)" title="${t('shopping.tag_title')}">🏷️</button>
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="${t('shopping.remove_title')}">✕</button>
</div>
</div>
<div class="shopping-tag-menu-container" style="display:none">${tagMenu}</div>
</div>
</div>`;
}
}
container.innerHTML = html;
}
function toggleShoppingTagMenu(btn) {
const container = btn.closest('.shopping-item-body').querySelector('.shopping-tag-menu-container');
if (!container) return;
const isOpen = container.style.display !== 'none';
// Close all other menus first
document.querySelectorAll('.shopping-tag-menu-container').forEach(c => c.style.display = 'none');
container.style.display = isOpen ? 'none' : 'block';
}
async function removeBringItem(idx) {
const item = shoppingItems[idx];
if (!item) return;
try {
const data = await api('bring_remove', {}, 'POST', {
name: item.name,
rawName: item.rawName || '',
listUUID: shoppingListUUID
});
if (data.success) {
shoppingItems.splice(idx, 1);
renderShoppingItems();
showToast(t('toast.removed_from_list_short'), 'success');
logOperation('bring_manual_remove', { name: item.name });
// Update dashboard shopping count
loadShoppingCount();
}
} catch (err) {
showToast(t('shopping.remove_error'), 'error');
}
}
async function generateSuggestions() {
const btn = document.getElementById('btn-suggest');
const suggestionsEl = document.getElementById('shopping-suggestions');
btn.disabled = true;
btn.innerHTML = `<div class="loading-spinner" style="display:inline-block;width:18px;height:18px;margin-right:8px;vertical-align:middle"></div> ${t('shopping.suggest_loading')}`;
suggestionsEl.style.display = 'none';
try {
const data = await api('bring_suggest', {}, 'POST', {});
btn.disabled = false;
btn.innerHTML = `🤖 ${t('shopping.suggest_btn').replace('🤖 ', '')}`;
if (!data.success) {
showToast(data.error || t('shopping.suggest_error'), 'error');
return;
}
suggestionItems = (data.suggestions || []).map(s => ({ ...s, selected: true }));
// Show seasonal tip
const tipEl = document.getElementById('seasonal-tip');
if (data.seasonal_tip) {
tipEl.style.display = 'block';
tipEl.innerHTML = `🌿 <em>${escapeHtml(data.seasonal_tip)}</em>`;
} else {
tipEl.style.display = 'none';
}
renderSuggestions();
suggestionsEl.style.display = 'block';
document.getElementById('suggestion-actions').style.display = 'block';
// Scroll to suggestions
suggestionsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
// AI enrich suggestions in background (best-effort)
if (_geminiAvailable && suggestionItems.length > 0) {
_enrichSuggestionsWithAI();
}
} catch (err) {
btn.disabled = false;
btn.innerHTML = `🤖 ${t('shopping.suggest_btn').replace('🤖 ', '')}`;
console.error('Suggestion error:', err);
showToast(t('error.connection'), 'error');
}
}
function renderSuggestions() {
const container = document.getElementById('suggestion-items');
const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 };
const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2));
container.innerHTML = sorted.map((item, idx) => {
const catIcon = CATEGORY_ICONS[item.category] || '🛒';
const priorityBadge = {
'alta': `<span class="priority-badge priority-high">${t('shopping.priority_high')}</span>`,
'media': `<span class="priority-badge priority-med">${t('shopping.priority_medium')}</span>`,
'bassa': `<span class="priority-badge priority-low">${t('shopping.priority_low')}</span>`,
}[item.priority] || '';
return `
<div class="suggestion-item ${item.selected ? 'selected' : ''}" onclick="toggleSuggestion(${idx})" data-suggestion-name="${escapeHtml(item.name)}">
<div class="suggestion-check">${item.selected ? '☑️' : '⬜'}</div>
<span class="shopping-item-icon">${catIcon}</span>
<div class="suggestion-info">
<div class="suggestion-name">${escapeHtml(item.name)}${item.specification ? ` <small>(${escapeHtml(item.specification)})</small>` : ''} ${priorityBadge}</div>
<div class="suggestion-reason">${escapeHtml(item.reason)}</div>
</div>
</div>`;
}).join('');
updateSuggestionActionBtn();
}
async function _enrichSuggestionsWithAI() {
try {
const items = suggestionItems.map(s => ({
name: s.name,
reason: s.reason || '',
category: s.category || '',
priority: s.priority || 'media',
}));
const data = await api('gemini_shopping_enrich', {}, 'POST', { items, lang: _currentLang });
if (!data.success || !Array.isArray(data.items)) return;
// For each item that has a tip, find its DOM element and append the tip
data.items.forEach(enriched => {
if (!enriched.tip) return;
const nameAttr = enriched.name.replace(/"/g, '&quot;');
const el = document.querySelector(`#suggestion-items [data-suggestion-name="${nameAttr}"]`);
if (!el) return;
const infoDiv = el.querySelector('.suggestion-info');
if (!infoDiv) return;
// Avoid duplicate tips
if (infoDiv.querySelector('.suggestion-ai-tip')) return;
const tipEl = document.createElement('div');
tipEl.className = 'suggestion-ai-tip';
tipEl.innerHTML = `💡 <em>${escapeHtml(enriched.tip)}</em>`;
infoDiv.appendChild(tipEl);
});
} catch (e) {
// best-effort — silently ignore
}
}
function toggleSuggestion(idx) {
const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 };
const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2));
const actualItem = sorted[idx];
// Find in original array
const origIdx = suggestionItems.indexOf(actualItem);
if (origIdx >= 0) {
suggestionItems[origIdx].selected = !suggestionItems[origIdx].selected;
}
renderSuggestions();
}
function updateSuggestionActionBtn() {
const selected = suggestionItems.filter(s => s.selected);
const btn = document.querySelector('#suggestion-actions .btn-success');
if (btn) {
const nItems = selected.length;
btn.textContent = `✅ ${nItems === 1 ? t('shopping.bring_add_one') : t('shopping.bring_add_many').replace('{n}', nItems)}`;
btn.disabled = nItems === 0;
}
}
async function addSelectedSuggestions() {
const selected = suggestionItems.filter(s => s.selected);
if (selected.length === 0) {
showToast(t('error.select_items'), 'error');
return;
}
const btn = document.querySelector('#suggestion-actions .btn-success');
btn.disabled = true;
btn.innerHTML = `<div class="loading-spinner" style="display:inline-block;width:18px;height:18px;margin-right:8px;vertical-align:middle"></div> ${t('shopping.bring_adding')}`;
try {
const items = selected.map(s => {
return { name: s.name };
});
const data = await api('bring_add', {}, 'POST', { items, listUUID: shoppingListUUID });
if (data.success) {
let msg = data.added === 1 ? t('shopping.bring_added_one') : t('shopping.bring_added_many').replace('{n}', data.added);
if (data.skipped > 0) msg += ` ${t('shopping.bring_skipped').replace('{n}', data.skipped)}`;
showToast(msg, 'success');
// Refresh list
await loadShoppingList();
// Update dashboard shopping count
loadShoppingCount();
// Clear suggestions
document.getElementById('shopping-suggestions').style.display = 'none';
suggestionItems = [];
} else {
showToast(data.error || t('error.generic'), 'error');
}
} catch (err) {
showToast(t('error.connection'), 'error');
}
btn.disabled = false;
btn.innerHTML = `✅ ${t('shopping.bring_add_selected')}`;
}
// ===== UTILITY FUNCTIONS =====
// ===== SCAN EXPIRY DATE WITH CAMERA + GEMINI AI =====
let expiryStream = null;
async function scanExpiryWithAI() {
if (!_requireGemini()) return;
// Create modal for camera capture
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${t('add.scan_expiry_title')}</h3>
<button class="modal-close" onclick="closeExpiryScanner()">✕</button>
</div>
<div class="expiry-scanner">
<div id="expiry-cam-container" style="height:180px;overflow:hidden;border-radius:10px;position:relative">
<video id="expiry-video" autoplay playsinline style="width:100%;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) scale(2);transform-origin:center center"></video>
<canvas id="expiry-canvas" style="display:none"></canvas>
<div style="position:absolute;inset:0;border:2px dashed rgba(255,255,255,0.5);border-radius:10px;pointer-events:none"></div>
</div>
<div id="expiry-preview-container" style="display:none;height:180px;overflow:hidden;border-radius:10px">
<img id="expiry-preview-img" src="" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:10px">
</div>
<p class="form-hint" style="text-align:center;margin:6px 0;font-size:0.8rem">${t('scanner.expiry_label_hint')}</p>
<div id="expiry-scan-status" style="display:none;text-align:center;padding:8px">
<div class="loading-spinner" style="margin:0 auto 6px"></div>
<p>${t('scanner.ai_analyzing')}</p>
</div>
<div class="expiry-scanner-actions">
<button class="btn btn-large btn-accent full-width" id="expiry-capture-btn" onclick="captureExpiry()">${t('scanner.capture_photo_btn')}</button>
<button class="btn btn-large btn-secondary full-width" id="expiry-retake-btn" onclick="retakeExpiry()" style="display:none">${t('scanner.retake_btn')}</button>
</div>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
// Start camera
try {
expiryStream = await navigator.mediaDevices.getUserMedia(getCameraConstraints());
const video = document.getElementById('expiry-video');
video.srcObject = expiryStream;
await video.play();
} catch (err) {
console.error('Expiry camera error:', err);
document.getElementById('expiry-cam-container').innerHTML = `
<p style="color:var(--danger);text-align:center;padding:20px">⚠️ Impossibile accedere alla fotocamera</p>
`;
}
}
function closeExpiryScanner() {
if (expiryStream) {
expiryStream.getTracks().forEach(t => t.stop());
expiryStream = null;
}
closeModal();
}
function captureExpiry() {
const video = document.getElementById('expiry-video');
const canvas = document.getElementById('expiry-canvas');
const img = document.getElementById('expiry-preview-img');
// Crop to center 50% (matching the 2x zoom view) for better AI accuracy
const sw = video.videoWidth / 2;
const sh = video.videoHeight / 2;
const sx = (video.videoWidth - sw) / 2;
const sy = (video.videoHeight - sh) / 2;
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, sw, sh);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
img.src = dataUrl;
// Stop camera
if (expiryStream) {
expiryStream.getTracks().forEach(t => t.stop());
expiryStream = null;
}
video.srcObject = null;
document.getElementById('expiry-cam-container').style.display = 'none';
document.getElementById('expiry-preview-container').style.display = 'block';
document.getElementById('expiry-capture-btn').style.display = 'none';
document.getElementById('expiry-retake-btn').style.display = 'block';
// Auto-analyze
analyzeExpiryImage(dataUrl);
}
function retakeExpiry() {
document.getElementById('expiry-cam-container').style.display = 'block';
document.getElementById('expiry-preview-container').style.display = 'none';
document.getElementById('expiry-capture-btn').style.display = 'block';
document.getElementById('expiry-retake-btn').style.display = 'none';
document.getElementById('expiry-scan-status').style.display = 'none';
// Restart camera
navigator.mediaDevices.getUserMedia(getCameraConstraints()).then(stream => {
expiryStream = stream;
const video = document.getElementById('expiry-video');
video.srcObject = stream;
video.play();
}).catch(err => console.error(err));
}
async function analyzeExpiryImage(dataUrl) {
const statusDiv = document.getElementById('expiry-scan-status');
statusDiv.style.display = 'block';
statusDiv.innerHTML = `<div class="loading-spinner" style="margin:0 auto 8px"></div><p>${t('scanner.ai_analyzing')}</p>`;
try {
// Remove data:image/jpeg;base64, prefix
const base64 = dataUrl.split(',')[1];
const result = await api('gemini_expiry', {}, 'POST', { image: base64 });
if (result.success && result.expiry_date) {
// Auto-fill the expiry date
const expiryInput = document.getElementById('add-expiry');
if (expiryInput) {
expiryInput.value = result.expiry_date;
}
statusDiv.innerHTML = `<p style="color:var(--success);font-weight:600">✅ Data trovata: ${formatDate(result.expiry_date)}</p>`;
// Close modal after delay
setTimeout(() => closeExpiryScanner(), 1500);
} else if (result.error === 'no_api_key') {
statusDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
} else {
statusDiv.innerHTML = `<p style="color:var(--danger)">❌ Non riesco a leggere la data. ${result.raw_text ? '<br><small>Letto: ' + escapeHtml(result.raw_text) + '</small>' : ''}</p>
<button class="btn btn-secondary" onclick="retakeExpiry()" style="margin-top:8px">${t('btn.retry')}</button>`;
}
} catch (err) {
console.error('Expiry AI error:', err);
statusDiv.innerHTML = `<p style="color:var(--danger)">❌ ${t('error.network_retry')}</p>
<button class="btn btn-secondary" onclick="retakeExpiry()" style="margin-top:8px">${t('btn.retry')}</button>`;
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
const _loc1 = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT';
return d.toLocaleDateString(_loc1, { day: '2-digit', month: 'short', year: 'numeric' });
}
function formatDateTime(dtStr) {
if (!dtStr) return '';
const d = new Date(dtStr.replace(' ', 'T'));
const _loc2 = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT';
return d.toLocaleDateString(_loc2, { day: '2-digit', month: 'short' }) + ' ' +
d.toLocaleTimeString(_loc2, { hour: '2-digit', minute: '2-digit' });
}
function daysUntilExpiry(dateStr) {
if (!dateStr) return Infinity;
const expiry = new Date(dateStr + 'T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((expiry - today) / 86400000);
}
function adjustQty(inputId, delta) {
const input = document.getElementById(inputId);
let val = parseFloat(input.value) || 0;
val = Math.max(0.1, val + delta);
input.value = Math.round(val * 10) / 10;
}
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'flex' : 'none';
}
function showToast(message, type = '') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show ' + type;
setTimeout(() => {
toast.className = 'toast';
}, 3000);
}
// ===== LOG =====
let _logOffset = 0;
const LOG_PAGE_SIZE = 50;
async function loadLog(more = false) {
if (!more) {
_logOffset = 0;
document.getElementById('log-list').innerHTML = '<p style="text-align:center;color:var(--text-muted)">Caricamento...</p>';
}
try {
const result = await api(`transactions_list&limit=${LOG_PAGE_SIZE}&offset=${_logOffset}`);
const txns = result.transactions || [];
let html = '';
if (!more && txns.length === 0) {
html = `<p style="text-align:center;color:var(--text-muted)">${t('log.empty')}</p>`;
} else {
let lastDate = more ? '' : null;
const _logLocale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT';
txns.forEach(tx => {
const dt = new Date(tx.created_at + 'Z');
const dateStr = dt.toLocaleDateString(_logLocale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const timeStr = dt.toLocaleTimeString(_logLocale, { hour: '2-digit', minute: '2-digit' });
if (dateStr !== lastDate) {
html += `<div class="log-date-header">${dateStr}</div>`;
lastDate = dateStr;
}
let icon, typeLabel, colorClass;
if (tx.type === 'bring') {
icon = '🛒';
typeLabel = t('log.type_bring');
colorClass = 'log-bring';
} else if (tx.type === 'in') {
icon = '';
typeLabel = t('log.type_added');
colorClass = 'log-in';
} else {
icon = '';
typeLabel = tx.type === 'waste' ? t('log.type_waste') : t('log.type_used');
colorClass = 'log-out';
}
const brand = tx.brand ? ` <em>(${tx.brand})</em>` : '';
const loc = tx.location || '';
const locLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
const locStr = tx.type === 'bring' ? '' : (locLabels[loc] || ('📍 ' + loc));
const isAnnotation = (tx.notes || '').includes('[Annullato]');
const isRecipeNote = !isAnnotation && (tx.notes || '').startsWith('Ricetta:');
const notes = tx.notes && !isAnnotation && !isRecipeNote ? ` · ${tx.notes}` : '';
const recipeNote = isRecipeNote ? `<div class="log-recipe-note">🍳 ${escapeHtml(tx.notes)}</div>` : '';
const undone = tx.undone == 1 || isAnnotation;
// Can undo if within 24h, not already undone, not a bring entry, not a counter-transaction
const ageMs = Date.now() - new Date(tx.created_at + 'Z').getTime();
const canUndo = !undone && tx.type !== 'bring' && ageMs < 86400000;
html += `<div class="log-entry ${colorClass}${undone ? ' log-undone' : ''}" id="log-entry-${tx.id}">`;
html += `<span class="log-icon">${icon}</span>`;
html += `<div class="log-info">`;
html += `<div class="log-product"><strong>${escapeHtml(tx.name)}</strong>${brand}${undone ? ` <span class="log-undone-badge">${t('log.undone_badge')}</span>` : ''}</div>`;
html += `<div class="log-detail">${typeLabel} ${tx.type !== 'bring' ? (tx.quantity + ' ' + (tx.unit || '')) + ' · ' : ''}${locStr}${notes} · ${timeStr}</div>`;
html += recipeNote;
html += `</div>`;
if (canUndo) {
html += `<button class="btn-log-undo" onclick="undoTransactionEntry(${tx.id}, '${escapeHtml(tx.type)}', '${escapeHtml(tx.name || '')}')" title="${t('log.undo_title')}">↩</button>`;
}
html += `</div>`;
});
}
if (more) {
document.getElementById('log-list').insertAdjacentHTML('beforeend', html);
} else {
document.getElementById('log-list').innerHTML = html;
}
_logOffset += txns.length;
document.getElementById('log-load-more').style.display = txns.length >= LOG_PAGE_SIZE ? '' : 'none';
} catch (err) {
console.error('Log load error:', err);
if (!more) document.getElementById('log-list').innerHTML = `<p style="text-align:center;color:var(--danger)">${t('log.load_error')}</p>`;
}
}
async function undoTransactionEntry(id, type, name) {
const action = type === 'in' ? t('log.undo_action_remove') : t('log.undo_action_restore');
const msg = t('log.undo_confirm').replace('{action}', action).replace('{name}', name);
_showDestructiveConfirm(
t('log.undo_title') || '↩ Annulla operazione',
msg,
() => _doUndoTransaction(id, type, name)
);
}
async function _doUndoTransaction(id, type, name) {
try {
const res = await api('transaction_undo', {}, 'POST', { id });
if (res.success) {
showToast(t('log.undo_success').replace('{name}', res.name || name), 'success');
// Mark the entry visually without reloading all
const el = document.getElementById(`log-entry-${id}`);
if (el) {
el.classList.add('log-undone');
const undoBtn = el.querySelector('.btn-log-undo');
if (undoBtn) undoBtn.remove();
const nameEl = el.querySelector('.log-product strong');
if (nameEl && !el.querySelector('.log-undone-badge')) {
nameEl.insertAdjacentHTML('afterend', ` <span class="log-undone-badge">${t('log.undone_badge')}</span>`);
}
}
} else if (res.already_undone) {
showToast(t('log.already_undone'), 'info');
} else if (res.too_old) {
showToast(t('log.too_old'), 'error');
} else {
showToast(res.error || t('log.undo_error'), 'error');
}
} catch (e) {
showToast(t('error.network'), 'error');
}
}
// ===== WEEKLY MEAL PLAN =====
/**
* All selectable meal categories per slot.
* id must be URL-safe; icon + label shown in UI.
*/
const MEAL_PLAN_TYPE_DEFS = [
{ id: 'pasta', icon: '🍝', i18nKey: 'meal_plan_types.pasta' },
{ id: 'riso', icon: '🍚', i18nKey: 'meal_plan_types.riso' },
{ id: 'carne', icon: '🥩', i18nKey: 'meal_plan_types.carne' },
{ id: 'pesce', icon: '🐟', i18nKey: 'meal_plan_types.pesce' },
{ id: 'legumi', icon: '🫘', i18nKey: 'meal_plan_types.legumi' },
{ id: 'uova', icon: '🥚', i18nKey: 'meal_plan_types.uova' },
{ id: 'formaggio', icon: '🧀', i18nKey: 'meal_plan_types.formaggio' },
{ id: 'pizza', icon: '🍕', i18nKey: 'meal_plan_types.pizza' },
{ id: 'affettati', icon: '🥓', i18nKey: 'meal_plan_types.affettati' },
{ id: 'verdure', icon: '🥦', i18nKey: 'meal_plan_types.verdure' },
{ id: 'zuppa', icon: '🍲', i18nKey: 'meal_plan_types.zuppa' },
{ id: 'insalata', icon: '🥗', i18nKey: 'meal_plan_types.insalata' },
{ id: 'pane', icon: '🥪', i18nKey: 'meal_plan_types.pane' },
{ id: 'dolce', icon: '🍰', i18nKey: 'meal_plan_types.dolce' },
{ id: 'libero', icon: '🎲', i18nKey: 'meal_plan_types.libero' },
];
function getMealPlanTypes() {
return MEAL_PLAN_TYPE_DEFS.map(mpt => ({ ...mpt, label: t(mpt.i18nKey) }));
}
function getMealPlanTypeMap() {
const map = {};
getMealPlanTypes().forEach(mpt => { map[mpt.id] = mpt; });
return map;
}
function getWeekDaysShortLabels() {
return [
t('days.mon_short'),
t('days.tue_short'),
t('days.wed_short'),
t('days.thu_short'),
t('days.fri_short'),
t('days.sat_short'),
t('days.sun_short'),
];
}
/** Default weekly plan as requested. */
const DEFAULT_MEAL_PLAN = {
1: { pranzo: 'pasta', cena: 'pesce' },
2: { pranzo: 'riso', cena: 'carne' },
3: { pranzo: 'legumi', cena: 'uova' },
4: { pranzo: 'pasta', cena: 'pesce' },
5: { pranzo: 'riso', cena: 'formaggio' },
6: { pranzo: 'legumi', cena: 'pizza' },
0: { pranzo: 'carne', cena: 'affettati' }, // 0 = Sunday (getDay())
};
function getMealPlan() {
const s = getSettings();
return s.meal_plan || DEFAULT_MEAL_PLAN;
}
/** Return today's planned meal type for a given slot ('pranzo'|'cena'), or null. */
function getTodayMealPlanType(slot) {
const s = getSettings();
if (s.meal_plan_enabled === false) return null;
const dow = new Date().getDay(); // 0=Sun,1=Mon,...,6=Sat
const plan = getMealPlan();
return plan[dow]?.[slot] || null;
}
/** Toggle handler for the enable/disable switch in settings. */
function onMealPlanEnabledChange(el) {
const s = getSettings();
s.meal_plan_enabled = el.checked;
saveSettingsToStorage(s);
const mpConfigSection = document.getElementById('meal-plan-config-section');
if (mpConfigSection) mpConfigSection.style.display = el.checked ? '' : 'none';
const mpLegendCard = document.getElementById('meal-plan-legend-card');
if (mpLegendCard) mpLegendCard.style.display = el.checked ? '' : 'none';
// Close picker if open
const picker = document.getElementById('meal-plan-picker');
if (picker) picker.style.display = 'none';
}
/**
* Render the weekly meal plan editor into #meal-plan-grid.
* Each cell shows the current type badge + a picker dropdown.
*/
function renderMealPlanEditor() {
const container = document.getElementById('meal-plan-grid');
if (!container) return;
const plan = getMealPlan();
// JS getDay: 0=Sun … but we display Mon-Sun (1..6,0)
const dayOrder = [1,2,3,4,5,6,0];
const today = new Date().getDay();
const mealPlanTypeMap = getMealPlanTypeMap();
const weekDaysShort = getWeekDaysShortLabels();
const header = `<div class="mplan-header">
<span class="mplan-col-header">🌤️ ${t('meal_types.pranzo')}</span>
<span class="mplan-col-header">🌙 ${t('meal_types.cena')}</span>
</div>`;
const rows = dayOrder.map((dow, i) => {
const pranzo = plan[dow]?.pranzo || 'libero';
const cena = plan[dow]?.cena || 'libero';
const pt = mealPlanTypeMap[pranzo] || mealPlanTypeMap.libero;
const ct = mealPlanTypeMap[cena] || mealPlanTypeMap.libero;
const todayClass = dow === today ? ' mplan-row-today' : '';
return `<div class="mplan-row${todayClass}">
<div class="mplan-day-name">${weekDaysShort[i]}</div>
<span class="mplan-badge mplan-badge-pranzo" onclick="openMealPlanPicker(${dow},'pranzo',this)">${pt.icon} ${pt.label}</span>
<span class="mplan-badge mplan-badge-cena" onclick="openMealPlanPicker(${dow},'cena',this)">${ct.icon} ${ct.label}</span>
</div>`;
}).join('');
container.innerHTML = header + rows;
}
let _mplanPickerTarget = null; // {dow, slot, badgeEl}
function openMealPlanPicker(dow, slot, badgeEl) {
// Close any open picker first
closeMealPlanPicker();
_mplanPickerTarget = { dow, slot, badgeEl };
const picker = document.getElementById('meal-plan-picker');
if (!picker) return;
const plan = getMealPlan();
const current = plan[dow]?.[slot] || 'libero';
picker.innerHTML = getMealPlanTypes().map(mpt =>
`<button class="mplan-pick-btn${mpt.id === current ? ' active' : ''}" onclick="selectMealPlanType(${dow},'${slot}','${mpt.id}')">${mpt.icon} ${mpt.label}</button>`
).join('');
// Position vertically near the badge, centered horizontally (CSS handles centering)
const rect = badgeEl.getBoundingClientRect();
const pickerEl = picker;
// Show first to measure height
pickerEl.style.display = 'flex';
const pickerH = pickerEl.offsetHeight || 160;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const top = spaceBelow >= pickerH
? rect.bottom + 8
: Math.max(8, rect.top - pickerH - 8);
pickerEl.style.top = top + 'px';
// Close on outside tap
setTimeout(() => document.addEventListener('click', _mplanPickerOutside, { once: true }), 0);
}
function _mplanPickerOutside(e) {
const picker = document.getElementById('meal-plan-picker');
if (picker && !picker.contains(e.target)) closeMealPlanPicker();
}
function closeMealPlanPicker() {
const picker = document.getElementById('meal-plan-picker');
if (picker) picker.style.display = 'none';
_mplanPickerTarget = null;
document.removeEventListener('click', _mplanPickerOutside);
}
function selectMealPlanType(dow, slot, typeId) {
const s = getSettings();
if (!s.meal_plan) s.meal_plan = JSON.parse(JSON.stringify(DEFAULT_MEAL_PLAN));
if (!s.meal_plan[dow]) s.meal_plan[dow] = {};
s.meal_plan[dow][slot] = typeId;
saveSettingsToStorage(s);
closeMealPlanPicker();
renderMealPlanEditor();
}
function resetMealPlan() {
const s = getSettings();
s.meal_plan = JSON.parse(JSON.stringify(DEFAULT_MEAL_PLAN));
saveSettingsToStorage(s);
renderMealPlanEditor();
showToast(t('meal_plan.reset_success'), 'success');
}
// ===== RECIPE GENERATION =====
const MEAL_TYPE_DEFS = [
{ id: 'colazione', icon: '☀️', i18nKey: 'meal_types.colazione', from: 6, to: 11 },
{ id: 'pranzo', icon: '🍽️', i18nKey: 'meal_types.pranzo', from: 11, to: 14 },
{ id: 'merenda', icon: '🍪', i18nKey: 'meal_types.merenda', from: 14, to: 17 },
{ id: 'cena', icon: '🌙', i18nKey: 'meal_types.cena', from: 17, to: 6 },
{ id: 'dolce', icon: '🍰', i18nKey: 'meal_types.dolce', from: -1, to: -1 },
{ id: 'succo', icon: '🧃', i18nKey: 'meal_types.succo', from: -1, to: -1 },
];
function getMealTypes() {
return MEAL_TYPE_DEFS.map(m => ({ ...m, label: t(m.i18nKey) }));
}
function getMealSubTypes() {
return {
dolce: [
{ id: 'torta', icon: '🎂', label: t('meal_sub.dolce_torta') },
{ id: 'crema', icon: '🍮', label: t('meal_sub.dolce_crema') },
{ id: 'crumble', icon: '🥧', label: t('meal_sub.dolce_crumble') },
{ id: 'biscotti', icon: '🍪', label: t('meal_sub.dolce_biscotti') },
{ id: 'frutta', icon: '🍓', label: t('meal_sub.dolce_frutta') },
],
succo: [
{ id: 'dolce', icon: '🍑', label: t('meal_sub.succo_dolce') },
{ id: 'energizzante', icon: '⚡', label: t('meal_sub.succo_energizzante') },
{ id: 'detox', icon: '🥬', label: t('meal_sub.succo_detox') },
{ id: 'rinfrescante', icon: '🧊', label: t('meal_sub.succo_rinfrescante') },
{ id: 'vitaminico', icon: '🍊', label: t('meal_sub.succo_vitaminico') },
]
};
}
function getMealLabels() {
const labels = {};
getMealTypes().forEach(m => { labels[m.id] = `${m.icon} ${m.label}`; });
return labels;
}
function getMealType() {
const hour = new Date().getHours();
for (const m of MEAL_TYPE_DEFS) {
if (m.from < m.to) { if (hour >= m.from && hour < m.to) return m.id; }
else { if (hour >= m.from || hour < m.to) return m.id; }
}
return 'cena';
}
function _normalizeMealId(rawMeal) {
if (!rawMeal) return '';
let meal = String(rawMeal).trim().toLowerCase();
meal = meal.replace(/^meal_types?\./, '');
if (meal === 'lunch') return 'pranzo';
if (meal === 'dinner') return 'cena';
return meal;
}
function _mealLabel(rawMeal) {
const mealId = _normalizeMealId(rawMeal);
const labels = getMealLabels();
if (labels[mealId]) return labels[mealId];
const translated = mealId ? t(`meal_types.${mealId}`) : '';
if (translated && translated !== `meal_types.${mealId}`) return translated;
return mealId || String(rawMeal || '');
}
function getSelectedMealType() {
const checked = document.querySelector('input[name="recipe-meal"]:checked');
return checked ? checked.value : getMealType();
}
// ===== RECIPE ARCHIVE (DB-backed) =====
let _recipeArchiveCache = null;
async function getRecipeArchive() {
if (_recipeArchiveCache !== null) return _recipeArchiveCache;
try {
const res = await api('recipes_list');
if (res.success) {
_recipeArchiveCache = res.recipes || [];
return _recipeArchiveCache;
}
} catch(e) { console.warn('Failed to load recipes from DB:', e); }
return [];
}
async function saveRecipeToArchive(recipe) {
const today = new Date().toISOString().slice(0, 10);
try {
await api('recipes_save', {}, 'POST', { date: today, meal: recipe.meal, recipe });
// Invalidate cache and refresh the archive list
_recipeArchiveCache = null;
loadRecipeArchive();
} catch(e) { console.error('Failed to save recipe:', e); }
}
async function getTodayRecipeTitles() {
const archive = await getRecipeArchive();
const today = new Date().toISOString().slice(0, 10);
return archive
.filter(e => e.date === today && e.recipe && e.recipe.title)
.map(e => e.recipe.title);
}
let _recipeArchiveEntries = [];
async function loadRecipeArchive() {
const container = document.getElementById('recipe-archive');
if (!container) return;
const archive = await getRecipeArchive();
_recipeArchiveEntries = archive;
if (archive.length === 0) {
container.innerHTML = `<div class="empty-state" style="padding:20px"><div class="empty-state-icon">🍳</div><p>${t('recipes.archive_empty')}</p></div>`;
return;
}
// Group by date
const byDate = {};
for (const entry of archive) {
if (!byDate[entry.date]) byDate[entry.date] = [];
byDate[entry.date].push(entry);
}
let html = '';
let flatIdx = 0;
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
for (const [date, entries] of Object.entries(byDate)) {
const _mealLocale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT';
let dateLabel = new Date(date + 'T12:00:00').toLocaleDateString(_mealLocale, { weekday: 'long', day: 'numeric', month: 'long' });
if (date === today) dateLabel = t('date.today');
else if (date === yesterday) dateLabel = t('date.yesterday');
html += `<div class="recipe-archive-day">`;
html += `<div class="recipe-archive-date">${escapeHtml(dateLabel)}</div>`;
for (const entry of entries) {
const r = entry.recipe;
const mealIcon = _mealLabel(r.meal || entry.meal);
const tags = (r.tags || []).slice(0, 3).join(', ');
// Find this entry's index in the flat archive array
const archiveIdx = archive.indexOf(entry);
html += `<div class="recipe-archive-card" onclick="viewArchivedRecipe(${archiveIdx})">`;
html += `<div class="recipe-archive-card-header">`;
html += `<span class="recipe-archive-meal">${mealIcon}</span>`;
html += `<span class="recipe-archive-title">${escapeHtml(r.title)}</span>`;
html += `</div>`;
html += `<div class="recipe-archive-card-meta">`;
if (r.prep_time) html += `<span>🔪 ${r.prep_time}</span>`;
if (r.cook_time) html += `<span>🔥 ${r.cook_time}</span>`;
html += `<span>👥 ${r.persons}</span>`;
if (tags) html += `<span>${tags}</span>`;
html += `</div></div>`;
flatIdx++;
}
html += `</div>`;
}
container.innerHTML = html;
}
function viewArchivedRecipe(idx) {
const entry = _recipeArchiveEntries[idx];
if (!entry) return;
_cachedRecipe = { meal: _normalizeMealId(entry.meal), recipe: entry.recipe };
renderRecipe(entry.recipe);
document.getElementById('recipe-overlay').style.display = 'flex';
document.getElementById('recipe-ask').style.display = 'none';
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = '';
}
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() {
if (!_requireGemini()) return;
const meal = getMealType();
const settings = getSettings();
document.getElementById('recipe-overlay').style.display = 'flex';
// Build meal selector radios
const mealGrid = document.getElementById('recipe-meal-grid');
if (mealGrid) {
mealGrid.innerHTML = getMealTypes().map(m => {
const checked = m.id === meal ? ' checked' : '';
return `<label class="recipe-meal-chip"><input type="radio" name="recipe-meal" value="${m.id}"${checked}> ${m.icon} ${m.label}</label>`;
}).join('');
}
updateRecipeMealTitle();
// Show today's meal plan hint
_renderMealPlanHint(meal);
// Check for cached recipe matching current meal type
if (_cachedRecipe && _cachedRecipe.meal === meal && _cachedRecipe.recipe) {
document.getElementById('recipe-ask').style.display = 'none';
document.getElementById('recipe-loading').style.display = 'none';
renderRecipe(_cachedRecipe.recipe);
document.getElementById('recipe-result').style.display = '';
return;
}
// Pre-fill persons from settings
document.getElementById('recipe-persons').value = settings.default_persons || 1;
// Pre-select option chips from settings
const prefMap = {
'veloce': 'recipe-opt-veloce',
'pocafame': 'recipe-opt-pocafame',
'scadenze': 'recipe-opt-scadenze',
'salutare': 'recipe-opt-healthy',
'opened': 'recipe-opt-opened',
'zerowaste': 'recipe-opt-zerowaste'
};
Object.entries(prefMap).forEach(([key, id]) => {
const cb = document.getElementById(id);
if (cb) cb.checked = settings.recipe_prefs && settings.recipe_prefs.includes(key);
});
document.getElementById('recipe-ask').style.display = '';
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = 'none';
}
// Toggle recipe option chip
function toggleRecipeOption(btn) {
btn.classList.toggle('active');
}
function closeRecipeDialog() {
document.getElementById('recipe-overlay').style.display = 'none';
}
function adjustRecipePersons(delta) {
const input = document.getElementById('recipe-persons');
let val = parseInt(input.value) || 1;
val = Math.max(1, Math.min(20, val + delta));
input.value = val;
}
let _recipeUseContext = null; // { idx, productId, btn, qtyNumber }
let _recipeUseConfMode = null;
let _recipeUseNormalUnit = 'pz';
async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, recipeQty) {
if (btn.disabled) return;
if (!qtyNumber || qtyNumber <= 0) qtyNumber = 1;
_recipeUseContext = { idx, productId, btn, qtyNumber, recipeQty };
_recipeUseConfMode = null;
// Fetch inventory to build the modal
try {
const data = await api('inventory_list');
const items = (data.inventory || []).filter(i => i.product_id == productId);
if (items.length === 0) {
showToast(t('error.not_in_inventory'), 'error');
return;
}
const unit = items[0].unit || 'pz';
const pkgSize = parseFloat(items[0].default_quantity) || 0;
const pkgUnit = items[0].package_unit || '';
const isConf = unit === 'conf' && pkgSize > 0 && pkgUnit;
// Find opened package location
const openedItem = items.find(_isOpenedInventoryItem);
const defaultLoc = openedItem ? openedItem.location : (items.find(i => i.location === location) ? location : items[0].location);
// Build location buttons
const productLocations = [...new Set(items.map(i => i.location))];
const locButtons = productLocations.map(loc => {
const locInfo = LOCATIONS[loc] || { icon: '📦', label: loc };
const locItems = items.filter(i => i.location === loc);
const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
const qtyLabel = formatQuantity(locQty, unit, pkgSize, pkgUnit);
const openedBadge = _locationHasOpenedPackage(items, loc)
? ` <span class="loc-opened-badge">🔓 ${t('use.opened_badge')}</span>`
: '';
return `<button type="button" class="loc-btn ${loc === defaultLoc ? 'active' : ''}${openedBadge ? ' loc-btn-opened' : ''}" onclick="selectRecipeUseLoc(this, '${loc}')">${locInfo.icon} ${locInfo.label}${openedBadge}<br><small>${qtyLabel}</small></button>`;
}).join('');
// Build quantity controls
let qtySection = '';
let defaultQtyValue = qtyNumber;
if (isConf) {
const totalConf = items.reduce((s, i) => s + parseFloat(i.quantity), 0);
const totalSub = totalConf * pkgSize;
const unitLabels = { 'ml': 'ml', 'g': 'g', 'pz': 'pz' };
const subLabel = unitLabels[pkgUnit] || pkgUnit;
_recipeUseConfMode = { packageSize: pkgSize, packageUnit: pkgUnit, totalSub, totalConf, subLabel, _activeUnit: 'sub' };
// qtyNumber from recipe is in sub-units (g, ml)
const step = getSubUnitStep(pkgUnit);
defaultQtyValue = qtyNumber;
qtySection = `
<div class="use-unit-switch" style="display:flex;margin-bottom:8px">
<button type="button" class="use-unit-btn active" id="ruse-unit-sub" onclick="switchRecipeUseUnit('sub')">${subLabel}</button>
<button type="button" class="use-unit-btn" id="ruse-unit-conf" onclick="switchRecipeUseUnit('conf')">${t('recipes.packs_label')}</button>
</div>
<p id="ruse-hint" style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.quantity_in_total').replace('{unit}', subLabel).replace('{total}', Math.round(totalSub) + subLabel)}</p>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)"></button>
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${step}" step="${step}" class="qty-input"
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
</div>`;
} else {
_recipeUseNormalUnit = unit;
const unitLabels = { 'pz': 'pz', 'g': 'g', 'ml': 'ml' };
const unitLabel = unitLabels[unit] || unit;
const inputMin = '0.1';
qtySection = `
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.amount_label')} (${unitLabel}):</p>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)"></button>
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${inputMin}" step="any" class="qty-input"
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
</div>`;
}
// Scale live UI: show only when scale is connected and unit is g or ml
const availInfo = items.map(i => {
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
return `${loc.icon} ${formatQuantity(i.quantity, i.unit, i.default_quantity, i.package_unit)}`;
}).join(' · ');
const showScaleLive = _scaleConnected && (unit === 'g' || unit === 'ml' ||
(_recipeUseConfMode && ((_recipeUseConfMode.packageUnit || '').toLowerCase() === 'g' || (_recipeUseConfMode.packageUnit || '').toLowerCase() === 'ml')));
const scaleLiveSection = showScaleLive ? `
<div id="ruse-scale-live-box" class="scale-live-box" style="flex-direction:column;align-items:stretch;border-color:var(--color-accent,#7c3aed)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span class="scale-live-icon">⚖️</span>
<span id="ruse-scale-live-val" class="scale-live-val" style="color:var(--color-accent,#7c3aed)">— —</span>
<span id="ruse-scale-live-status" style="font-size:0.75rem;color:var(--text-muted);margin-left:auto"></span>
</div>
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-bottom:4px">
<div id="ruse-scale-progress-bar" style="height:100%;width:0%;background:var(--color-accent,#7c3aed);transition:none;border-radius:2px"></div>
</div>
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;display:none" id="ruse-scale-confirm-wrap">
<div id="ruse-scale-confirm-bar" style="height:100%;width:100%;background:#22c55e;transition:none;border-radius:2px"></div>
</div>
<div id="ruse-scale-live-label" class="scale-live-label" style="margin-top:3px">${t('recipes.scale_wait_stable')}</div>
</div>` : '';
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>📤 ${t('recipes.use_ingredient_title')}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div style="padding:0 16px 16px">
<p style="margin-bottom:4px;font-weight:600">${escapeHtml(items[0].name)}</p>
${recipeQty ? `<p style="margin-bottom:8px;background:var(--bg-elevated,rgba(124,58,237,0.12));border-left:3px solid var(--color-accent,#7c3aed);border-radius:6px;padding:6px 10px;font-size:0.9rem">📋 ${t('recipes.recipe_qty_label')}: <strong>${escapeHtml(recipeQty)}</strong></p>` : ''}
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:12px">📦 ${availInfo}</p>
${scaleLiveSection}
<div class="form-group">
<label>📍 ${t('recipes.from_where_label')}</label>
<div class="location-selector">${locButtons}</div>
<input type="hidden" id="ruse-location" value="${defaultLoc}">
</div>
<div class="form-group">
<label>${t('recipes.amount_label')}?</label>
${qtySection}
<small id="ruse-scale-hint" style="display:none; color: var(--color-accent, #7c3aed); margin-top:4px"></small>
</div>
<button type="button" id="btn-ruse-submit" class="btn btn-large btn-danger full-width move-countdown-btn" onclick="submitRecipeUse(false)" style="margin-top:8px">
📤 ${t('recipes.use_amount_btn')}
</button>
<button type="button" class="btn btn-large btn-secondary full-width" style="margin-top:8px" onclick="submitRecipeUse(true)">
🗑️ ${t('recipes.use_all_btn')}
</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
} catch (err) {
console.error('useRecipeIngredient error:', err);
showToast(t('recipes.load_error'), 'error');
}
}
function selectRecipeUseLoc(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('ruse-location').value = loc;
}
function switchRecipeUseUnit(mode) {
if (!_recipeUseConfMode) return;
const subBtn = document.getElementById('ruse-unit-sub');
const confBtn = document.getElementById('ruse-unit-conf');
const qtyInput = document.getElementById('ruse-quantity');
const hint = document.getElementById('ruse-hint');
if (mode === 'sub') {
subBtn.classList.add('active');
confBtn.classList.remove('active');
_recipeUseConfMode._activeUnit = 'sub';
const step = getSubUnitStep(_recipeUseConfMode.packageUnit);
qtyInput.value = _recipeUseContext.qtyNumber || step;
qtyInput.step = step;
qtyInput.min = step;
hint.textContent = t('recipes.quantity_in_total').replace('{unit}', _recipeUseConfMode.subLabel).replace('{total}', Math.round(_recipeUseConfMode.totalSub) + _recipeUseConfMode.subLabel);
} else {
confBtn.classList.add('active');
subBtn.classList.remove('active');
_recipeUseConfMode._activeUnit = 'conf';
qtyInput.value = 1;
qtyInput.step = 0.5;
qtyInput.min = 0.5;
hint.textContent = t('recipes.packs_of_have').replace('{size}', `${_recipeUseConfMode.packageSize}${_recipeUseConfMode.subLabel}`).replace('{count}', _recipeUseConfMode.totalConf.toFixed(1));
}
}
function adjustRecipeUseQty(direction) {
const input = document.getElementById('ruse-quantity');
let val = parseFloat(input.value) || 0;
let step;
if (_recipeUseConfMode && _recipeUseConfMode._activeUnit === 'sub') {
step = getSubUnitStep(_recipeUseConfMode.packageUnit);
} else if (_recipeUseConfMode && _recipeUseConfMode._activeUnit === 'conf') {
step = 0.5;
} else {
const u = _recipeUseNormalUnit || 'pz';
if (u === 'g' || u === 'ml') {
step = val < 50 ? 1 : (val < 500 ? 10 : 50);
} else {
step = 1;
}
}
val = Math.max(step, val + direction * step);
input.value = Math.round(val * 1000) / 1000;
}
async function submitRecipeUse(useAll) {
if (!_recipeUseContext) return;
const { idx, productId, btn } = _recipeUseContext;
const location = document.getElementById('ruse-location').value;
let qty;
if (useAll) {
qty = 0; // API handles use_all
} else {
qty = parseFloat(document.getElementById('ruse-quantity').value) || 1;
if (_recipeUseConfMode && _recipeUseConfMode._activeUnit === 'sub') {
qty = qty / _recipeUseConfMode.packageSize;
}
}
closeModal();
btn.disabled = true;
btn.textContent = '⏳...';
try {
const recipeTitle = _cachedRecipe?.recipe?.title || '';
const result = await api('inventory_use', {}, 'POST', {
product_id: productId,
quantity: qty,
use_all: useAll,
location: location,
notes: recipeTitle ? `Ricetta: ${recipeTitle}` : '',
});
if (result.success) {
const li = document.getElementById(`recipe-ing-${idx}`);
if (li) li.classList.add('recipe-ing-used');
btn.textContent = t('cooking.ingredient_used');
btn.classList.add('btn-used');
if (_cachedRecipe && _cachedRecipe.recipe && _cachedRecipe.recipe.ingredients && _cachedRecipe.recipe.ingredients[idx]) {
_cachedRecipe.recipe.ingredients[idx].used = true;
// Persist used state to DB
saveRecipeToArchive(_cachedRecipe.recipe);
}
showToast(t('recipes.ingredient_scaled_toast'), 'success');
if (result.added_to_bring) {
setTimeout(() => showToast(t('recipes.finished_added_bring_toast'), 'info'), 1500);
}
// Check low stock → Bring! prompt, then offer move
const moveCallback = result.remaining > 0
? () => setTimeout(() => {
const ingData = _cachedRecipe?.recipe?.ingredients?.[_recipeUseContext?.idx];
const wasVacuum = !!(ingData?.vacuum_sealed);
showRecipeMoveModal(productId, location, result.remaining, result.opened_id, wasVacuum);
}, 300)
: null;
setTimeout(() => showLowStockBringPrompt(result, moveCallback), 300);
} else {
btn.disabled = false;
btn.textContent = t('cooking.ingredient_use_btn');
showToast(result.error || t('error.generic'), 'error');
}
} catch (err) {
console.error('Recipe use error:', err);
btn.disabled = false;
btn.textContent = t('cooking.ingredient_use_btn');
showToast(t('error.connection'), 'error');
}
_recipeUseContext = null;
}
function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum) {
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
const locButtons = otherLocs.map(([k, v]) =>
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmRecipeMove(${productId}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
).join('');
const vacuumRow = wasVacuum ? `
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
<input type="checkbox" id="move-vacuum-check" checked>
<span>${t('move.vacuum_restore')}</span>
</label>` : '';
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${t('move.title')}</h3>
<button class="modal-close" onclick="clearMoveModalTimer();closeModal()">✕</button>
</div>
<div style="padding:0 16px 16px">
<p style="margin-bottom:12px">${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}</p>
<div class="location-selector">${locButtons}</div>
${vacuumRow}
<button type="button" id="btn-move-stay" class="btn btn-secondary full-width move-countdown-btn" style="margin-top:12px" onclick="clearMoveModalTimer();closeModal()">No, resta in ${LOCATIONS[fromLoc]?.label || fromLoc}</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
startMoveModalCountdown('btn-move-stay', () => { closeModal(); });
}
async function confirmRecipeMove(productId, fromLoc, toLoc, openedId) {
clearMoveModalTimer();
const newVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0;
closeModal();
try {
if (openedId) {
let days = estimateExpiryDays({ name: '', category: '' }, toLoc);
if (newVacuum) days = getVacuumExpiryDays(days);
await api('inventory_update', {}, 'POST', {
id: openedId,
location: toLoc,
expiry_date: addDays(days),
product_id: productId,
vacuum_sealed: newVacuum,
});
} else {
const data = await api('inventory_list');
const item = (data.inventory || []).find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0);
if (item) {
let days = estimateExpiryDays({ name: item.name || '', category: item.category || '' }, toLoc);
if (newVacuum) days = getVacuumExpiryDays(days);
await api('inventory_update', {}, 'POST', {
id: item.id,
location: toLoc,
expiry_date: addDays(days),
product_id: productId,
vacuum_sealed: newVacuum,
});
}
}
showToast(`📦 Spostato in ${LOCATIONS[toLoc]?.label || toLoc}`, 'success');
} catch (e) {
console.error('Recipe move error:', e);
}
}
function renderRecipe(r) {
let html = `<h2>${r.title}</h2>`;
// Meta tags
html += '<div class="recipe-meta">';
html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
html += `<span class="recipe-tag">👥 ${r.persons} ${t('recipes.persons_short')}</span>`;
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
if (r.tags) r.tags.forEach(t => { html += `<span class="recipe-tag">${t}</span>`; });
html += '</div>';
// Expiry note
if (r.expiry_note) {
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
}
// Ingredients
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
(r.ingredients || []).forEach((ing, idx) => {
if (ing.from_pantry && ing.product_id) {
const qtyNum = ing.qty_number || 0;
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const alreadyUsed = ing.used === true;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}">`;
html += `<span class="recipe-ing-text"><strong>${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: ${ing.qty} ✅`;
// Detail line: location + expiry
let details = [];
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
details.push(ingredientLocLabels[ing.location] || ('📍 ' + ing.location));
if (ing.expiry_date) {
const exp = new Date(ing.expiry_date);
const now = new Date(); now.setHours(0,0,0,0);
const diffDays = Math.round((exp - now) / 86400000);
if (diffDays < 0) details.push(t('expiry.badge_expired_ago').replace('{n}', Math.abs(diffDays)));
else if (diffDays <= 3) details.push(t('expiry.badge_expires_red').replace('{n}', diffDays));
else if (diffDays <= 7) details.push(t('expiry.badge_expires_yellow').replace('{n}', diffDays));
else details.push('📅 ' + exp.toLocaleDateString(_currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT'));
}
if (details.length) html += `<br><small class="recipe-ing-detail">${details.join(' · ')}</small>`;
html += `</span>`;
if (alreadyUsed) {
html += `<button class="btn-use-ingredient btn-used" disabled>${t('cooking.ingredient_used')}</button>`;
} else {
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this, '${(ing.qty || '').replace(/'/g, "&apos;")}')" title="${t('cooking.ingredient_deduct_title')}">${t('cooking.ingredient_use_btn')}</button>`;
}
html += `</li>`;
} else {
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
html += `<li class="recipe-ingredient"><span class="recipe-ing-text"><strong>${ing.name}</strong>: ${ing.qty}${pantryIcon}</span></li>`;
}
});
html += '</ul>';
// Steps
html += `<h3>${t('recipes.steps_title')}</h3><ol>`;
(r.steps || []).forEach(step => {
const cleanStep = step.replace(/^Passo\s*\d+\s*:\s*/i, '');
html += `<li>${cleanStep}</li>`;
});
html += '</ol>';
// Nutrition note
if (r.nutrition_note) {
html += `<p style="color:var(--text-muted);font-size:0.85rem;margin-top:12px">💡 ${r.nutrition_note}</p>`;
}
document.getElementById('recipe-content').innerHTML = html;
}
// ===== COOKING MODE =====
let _cookingRecipe = null;
let _cookingStep = 0;
let _cookingTTS = true;
let _cookingVisited = new Set(); // indices of steps already seen
function startCookingMode() {
const recipe = _cachedRecipe && _cachedRecipe.recipe ? _cachedRecipe.recipe : null;
if (!recipe || !(recipe.steps || []).length) {
showToast(t('recipes.no_steps'), 'info');
return;
}
// Resume if same recipe; otherwise start fresh
const isSame = _cookingRecipe && _cookingRecipe.title === recipe.title;
if (!isSame) {
_cookingRecipe = JSON.parse(JSON.stringify(recipe));
_cookingStep = 0;
_cookingVisited = new Set();
clearAllCookingTimers();
}
_cookingTTS = true;
document.getElementById('cooking-title').textContent = _cookingRecipe.title || '';
document.getElementById('cooking-tts-btn').textContent = '🔊';
document.getElementById('cooking-overlay').style.display = 'flex';
document.body.classList.add('cooking-mode-active');
try { screen.orientation?.lock('portrait').catch(() => {}); } catch (_) { /* ignore */ }
renderCookingStep();
if (_cookingTTS) {
const text = ((_cookingRecipe.steps || [])[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
speakCookingStep(text);
}
}
function closeCookingMode() {
document.getElementById('cooking-overlay').style.display = 'none';
document.body.classList.remove('cooking-mode-active');
// NOTE: intentionally keep _cookingRecipe, _cookingStep, _cookingVisited
// so the user can resume from the same step when they reopen
try { screen.orientation?.unlock().catch(() => {}); } catch (_) { /* ignore */ }
}
function restartCookingMode() {
_cookingStep = 0;
_cookingVisited = new Set();
clearAllCookingTimers();
renderCookingStep();
}
function renderCookingStep() {
if (!_cookingRecipe) return;
const steps = _cookingRecipe.steps || [];
const step = steps[_cookingStep] || '';
const cleanStep = step.replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
const total = steps.length;
// Mark current step as visited
_cookingVisited.add(_cookingStep);
document.getElementById('cooking-step-num').textContent = `${_cookingStep + 1} / ${total}`;
document.getElementById('cooking-step-text').textContent = cleanStep;
// Progress dots
const dotsEl = document.getElementById('cooking-progress-dots');
if (dotsEl) {
dotsEl.innerHTML = Array.from({ length: total }, (_, i) => {
let cls = 'cprog-dot';
if (i === _cookingStep) cls += ' current';
else if (_cookingVisited.has(i)) cls += ' visited';
return `<span class="${cls}"></span>`;
}).join('');
}
// Show ALL unused from_pantry ingredients (not filtered by step text).
// The AI often uses pronouns ("tagliarla", "aggiungile") instead of the ingredient
// name, so text-matching would miss them. Better to always show what's available.
const ings = (_cookingRecipe.ingredients || [])
.map((ing, idx) => ({ ...ing, _idx: idx }))
.filter(ing => ing.from_pantry && ing.product_id && ing.used !== true);
const ingsEl = document.getElementById('cooking-step-ings');
if (ings.length > 0) {
const cookingLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
ingsEl.innerHTML = ings.map(ing => {
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const qtyNum = ing.qty_number || 0;
// Build info chips: brand, location, expiry
const chips = [];
if (ing.brand) chips.push(`<span class="cooking-ing-chip">${escapeHtml(ing.brand)}</span>`);
const locLabel = cookingLocLabels[ing.location] || (ing.location ? `📍 ${ing.location}` : `${LOCATIONS.dispensa.icon} ${LOCATIONS.dispensa.label}`);
chips.push(`<span class="cooking-ing-chip">${locLabel}</span>`);
if (ing.expiry_date) {
const daysLeft = Math.round((new Date(ing.expiry_date) - new Date()) / 86400000);
const expClass = daysLeft <= 3 ? 'exp-soon' : daysLeft <= 7 ? 'exp-close' : '';
chips.push(`<span class="cooking-ing-chip ${expClass}">📅 ${t('cooking.expires_chip').replace('{date}', formatDate(ing.expiry_date))}</span>`);
}
return `<div class="cooking-ing-row">
<div style="flex:1;min-width:0">
<span class="cooking-ing-name">📦 <strong>${escapeHtml(ing.name)}</strong>: ${escapeHtml(ing.qty)}</span>
<div class="cooking-ing-meta">${chips.join('')}</div>
</div>
<button class="cooking-use-btn" onclick="cookingUseIngredient(${ing._idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this)">${t('cooking.ingredient_use_btn')}</button>
</div>`;
}).join('');
ingsEl.style.display = 'flex';
} else {
ingsEl.innerHTML = '';
ingsEl.style.display = 'none';
}
// Navigation button states
const prevBtn = document.getElementById('cooking-prev');
const nextBtn = document.getElementById('cooking-next');
prevBtn.disabled = _cookingStep === 0;
nextBtn.textContent = _cookingStep === total - 1 ? t('cooking.finish') : t('cooking.next');
// Timer: detect duration in step text and show suggestion
setupCookingTimerSuggestion(cleanStep);
// TTS: auto-speak is handled by navigateCookingStep() and startCookingMode() callers.
// Use replayCookingTTS() to re-read the current step manually ("Rileggi" button).
}
function _buildTtsRequest(text, s) {
const url = s.tts_url || '';
const method = s.tts_method || 'POST';
const authType = s.tts_auth_type || 'bearer';
const token = s.tts_token || '';
const payloadKey = s.tts_payload_key || 'message';
const contentType = s.tts_content_type || 'application/json';
let extraFields = {};
try { extraFields = JSON.parse(s.tts_extra_fields || '{}'); } catch(e) { /* invalid JSON, ignore */ }
const headers = { 'Content-Type': contentType };
if (authType === 'bearer' && token) {
headers['Authorization'] = `Bearer ${token}`;
} else if (authType === 'header' && s.tts_auth_header_name) {
headers[s.tts_auth_header_name] = s.tts_auth_header_value || '';
}
const payload = { [payloadKey]: text, ...extraFields };
let body;
if (contentType === 'application/json') {
body = JSON.stringify(payload);
} else if (contentType === 'application/x-www-form-urlencoded') {
body = new URLSearchParams(Object.entries(payload).map(([k, v]) => [k, String(v)])).toString();
} else {
body = text;
}
return { url, method, headers, body };
}
async function _ttsViaProxy(req) {
// Route through server-side proxy to avoid mixed-content / CORS issues
return fetch('api/index.php?action=tts_proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: req.url,
method: req.method,
headers: req.headers,
payload: req.body
})
});
}
async function speakCookingStep(text) {
if (!text) return;
const s = getSettings();
// Use custom TTS endpoint only when explicitly configured; otherwise always use browser TTS.
// Do NOT gate on s.tts_enabled — the _cookingTTS toggle in cooking mode is the only gate.
try {
if (s.tts_engine === 'custom' && s.tts_url) {
const req = _buildTtsRequest(text, s);
await _ttsViaProxy(req);
} else {
_speakBrowser(text);
}
} catch(e) { /* silent — TTS is non-critical */ }
}
function replayCookingTTS() {
if (!_cookingRecipe) return;
const steps = _cookingRecipe.steps || [];
const text = (steps[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
if (text) speakCookingStep(text);
}
function onTtsAuthTypeChange(type) {
const tokenGroup = document.getElementById('tts-token-group');
const headerGroup = document.getElementById('tts-custom-header-group');
if (tokenGroup) tokenGroup.style.display = type === 'bearer' ? '' : 'none';
if (headerGroup) headerGroup.style.display = type === 'header' ? '' : 'none';
}
function onTtsEngineChange(engine) {
const browserSect = document.getElementById('tts-browser-section');
const serverSect = document.getElementById('tts-server-section');
if (browserSect) browserSect.style.display = engine === 'browser' ? '' : 'none';
if (serverSect) serverSect.style.display = engine === 'server' ? '' : 'none';
}
/** Populate voice selector from Web Speech API. Called on settings load and on voiceschanged. */
function _initBrowserTtsVoices(selectedVoice) {
const sel = document.getElementById('setting-tts-voice');
if (!sel) return;
// Inside the EverShelf Kiosk Android app the native TTS bridge handles
// speech — no Web Speech API voice list needed.
if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') {
sel.innerHTML = '<option value="">— Voce nativa Android (kiosk) —</option>';
return;
}
if (!window.speechSynthesis) {
sel.innerHTML = '<option value="">— Voce non supportata dal browser —</option>';
return;
}
// Reset to loading state each time (settings page may be re-opened)
sel.innerHTML = '<option value="">— Caricamento voci… —</option>';
const populate = () => {
const voices = window.speechSynthesis.getVoices();
if (!voices.length) return false;
// Italian voices first, then others
const it = voices.filter(v => v.lang.startsWith('it'));
const others = voices.filter(v => !v.lang.startsWith('it'));
const sorted = [...it, ...others];
sel.innerHTML = sorted.map(v =>
`<option value="${v.name}" ${v.name === selectedVoice ? 'selected' : ''}>${v.name} (${v.lang})${v.localService ? '' : ' ☁️'}</option>`
).join('');
// Auto-select first Italian voice if no preference set
if (!selectedVoice) {
const paola = sorted.find(v => v.name === 'Paola');
const firstIt = sorted.find(v => v.lang.startsWith('it'));
if (paola) sel.value = paola.name;
else if (firstIt) sel.value = firstIt.name;
}
return true;
};
// Try immediately (voices already cached from previous call)
if (populate()) return;
// onvoiceschanged fires in Firefox / some Chrome versions
window.speechSynthesis.onvoiceschanged = () => { populate(); };
// Polling fallback: Chrome/WebView loads voices async (up to ~3s on desktop, longer on Android)
let tries = 0;
const interval = setInterval(() => {
tries++;
if (populate()) {
clearInterval(interval);
} else if (tries >= 50) { // 50 × 200ms = 10s
clearInterval(interval);
if (!window.speechSynthesis.getVoices().length) {
sel.innerHTML = '<option value="">— Nessuna voce disponibile su questo dispositivo —</option>';
}
}
}, 200);
}
/** Speak text using the browser Web Speech API (offline).
* When running inside the EverShelf Kiosk Android app the native TTS bridge
* is preferred — it bypasses Web Speech API voice limitations on Android. */
function _speakBrowser(text) {
const s = getSettings();
const rate = parseFloat(s.tts_rate) || 1;
const pitch = parseFloat(s.tts_pitch) || 1;
// ── Native Android TTS bridge (kiosk WebView) ──────────────────────
if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') {
try { _kioskBridge.speak(text, rate, pitch); } catch(_e) { /* silent */ }
return;
}
// ── Web Speech API (desktop / mobile browser) ──────────────────────
if (!window.speechSynthesis) return;
window.speechSynthesis.cancel();
const utt = new SpeechSynthesisUtterance(text);
utt.rate = rate;
utt.pitch = pitch;
const voices = window.speechSynthesis.getVoices();
const preferred = voices.find(v => v.name === s.tts_voice);
if (preferred) {
utt.voice = preferred;
utt.lang = preferred.lang;
} else {
utt.lang = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-US' : 'it-IT';
}
window.speechSynthesis.speak(utt);
}
async function testTTS() {
const statusEl = document.getElementById('tts-test-status');
const enabled = document.getElementById('setting-tts-enabled')?.checked;
if (!enabled) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ TTS non attivo — attiva il toggle prima di testare.'; }
return;
}
const engine = document.getElementById('setting-tts-engine')?.value || 'browser';
if (engine === 'browser') {
// Kiosk native TTS bridge takes priority over Web Speech API
if (typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function') {
const s = getSettings();
s.tts_rate = parseFloat(document.getElementById('setting-tts-rate')?.value) || 1;
s.tts_pitch = parseFloat(document.getElementById('setting-tts-pitch')?.value) || 1;
saveSettingsToStorage(s);
_speakBrowser('Test vocale EverShelf. La sintesi vocale funziona correttamente.');
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Riproduzione in corso — controlla l\'audio del dispositivo.'; }
return;
}
if (!window.speechSynthesis) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Web Speech API non supportata da questo browser.'; }
return;
}
// Temporarily apply form values for the test
const s = getSettings();
const voiceName = document.getElementById('setting-tts-voice')?.value;
s.tts_voice = voiceName || s.tts_voice;
s.tts_rate = parseFloat(document.getElementById('setting-tts-rate')?.value) || 1;
s.tts_pitch = parseFloat(document.getElementById('setting-tts-pitch')?.value) || 1;
saveSettingsToStorage(s);
_speakBrowser('Test vocale EverShelf. La sintesi vocale funziona correttamente.');
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Riproduzione in corso — controlla l\'audio del dispositivo.'; }
return;
}
// Server engine
let extraFields = {};
try { extraFields = JSON.parse((document.getElementById('setting-tts-extra-fields')?.value || '{}').trim() || '{}'); } catch(e) { /* ignore */ }
const formSettings = {
tts_url: (document.getElementById('setting-tts-url')?.value || '').trim(),
tts_method: document.getElementById('setting-tts-method')?.value || 'POST',
tts_auth_type: document.getElementById('setting-tts-auth-type')?.value || 'bearer',
tts_token: (document.getElementById('setting-tts-token')?.value || '').trim(),
tts_auth_header_name: (document.getElementById('setting-tts-auth-header-name')?.value || '').trim(),
tts_auth_header_value: (document.getElementById('setting-tts-auth-header-value')?.value || '').trim(),
tts_content_type: document.getElementById('setting-tts-content-type')?.value || 'application/json',
tts_payload_key: (document.getElementById('setting-tts-payload-key')?.value || '').trim() || 'message',
tts_extra_fields: document.getElementById('setting-tts-extra-fields')?.value || ''
};
if (!formSettings.tts_url) {
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '⚠️ URL endpoint mancante.'; }
return;
}
if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Invio in corso…'; }
try {
const req = _buildTtsRequest('Test vocale EverShelf', formSettings);
const res = await _ttsViaProxy(req);
const data = await res.json().catch(() => ({}));
const httpCode = data.status || res.status;
if (res.ok && httpCode >= 200 && httpCode < 300) {
if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ Risposta ${httpCode} — controlla che l'altoparlante abbia parlato.`; }
} else {
const errDetail = data.error || data.body || res.statusText;
if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `⚠️ HTTP ${httpCode}: ${errDetail}`; }
}
} catch(e) {
if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ Errore: ${e.message}`; }
}
}
// ===== COOKING TIMER SYSTEM =====
let _cookingTimers = []; // { id, label, total, seconds, running, interval }
let _cookingTimerIdCounter = 0;
let _cookingSuggestedSeconds = 0;
let _cookingSuggestedLabel = '';
/**
* Parse time durations from step text.
* Returns total seconds or 0 if no time found.
*/
function _parseStepTimer(text) {
const t = text.toLowerCase();
let totalSec = 0;
if (/mezz['']?\s*ora/i.test(t)) totalSec += 30 * 60;
if (/un\s+quarto\s+d['']?\s*ora/i.test(t)) totalSec += 15 * 60;
if (/un['']?\s*ora(?!\s*e)/i.test(t) && !/\d\s*or[ae]/i.test(t)) totalSec += 60 * 60;
if (totalSec > 0) return totalSec;
const reOre = /(\d+(?:[.,]\d+)?)\s*or[ae]/gi;
const reMin = /(\d+(?:[.,]\d+)?)\s*min(?:ut[oi])?/gi;
const reSec = /(\d+(?:[.,]\d+)?)\s*second[oi]/gi;
let m;
while ((m = reOre.exec(t)) !== null) totalSec += parseFloat(m[1].replace(',', '.')) * 3600;
while ((m = reMin.exec(t)) !== null) totalSec += parseFloat(m[1].replace(',', '.')) * 60;
while ((m = reSec.exec(t)) !== null) totalSec += parseFloat(m[1].replace(',', '.'));
if (totalSec === 0 && /(?:un\s+paio\s+di|qualche|pochi)\s+minut/i.test(t)) totalSec = 2 * 60;
if (totalSec === 0 && /qualche\s+second/i.test(t)) totalSec = 15;
return Math.round(totalSec);
}
function _formatTimerDisplay(sec) {
const abs = Math.abs(sec);
const m = Math.floor(abs / 60);
const s = abs % 60;
const sign = sec < 0 ? '+' : '';
return `${sign}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
/** Extract a short 2-3 word label from the step text for the timer. */
function _extractTimerLabel(text, stepNum) {
const fillers = new Set(['il','la','lo','le','gli','i','dell','della','dello','delle','degli','dei',
'un','una','uno','del','al','alla','allo','alle','agli','ai','nel','nella','nello','nelle',
'negli','nei','per','con','che','poi','e','o','non','se','in','di','a','da','fino','mentre',
'quando','dopo','prima','circa','bene','ancora','subito','su','ad','ed','più','meno','tutto','tutta']);
const timePatterns = [/mezz['']?\s*ora/i, /\bor[ae]\b/i, /\bmin(?:ut[oi])?\b/i, /\bsecond[oi]\b/i, /\bquarto\s+d['']?\s*ora/i];
let timeIdx = text.length;
for (const p of timePatterns) { const r = p.exec(text); if (r && r.index < timeIdx) timeIdx = r.index; }
const beforeTime = (text.slice(0, timeIdx).trim() || text);
const words = beforeTime.replace(/[.,!?;:'"()\[\]]/g, '').split(/\s+/).filter(w => w.length > 2 && !/^\d+$/.test(w));
const meaningful = words.filter(w => !fillers.has(w.toLowerCase()));
if (meaningful.length >= 1) return meaningful.slice(0, 3).join(' ');
return `Passo ${stepNum + 1}`;
}
function setupCookingTimerSuggestion(stepText) {
const seconds = _parseStepTimer(stepText);
const suggestEl = document.getElementById('cooking-timer-suggest');
if (seconds <= 0) {
suggestEl.style.display = 'none';
_cookingSuggestedSeconds = 0;
_cookingSuggestedLabel = '';
return;
}
_cookingSuggestedSeconds = seconds;
_cookingSuggestedLabel = _extractTimerLabel(stepText, _cookingStep);
document.getElementById('cooking-timer-suggest-text').textContent =
`⏱️ ${_formatTimerDisplay(seconds)} · ${_cookingSuggestedLabel}`;
suggestEl.style.display = 'flex';
}
function addSuggestedCookingTimer() {
if (_cookingSuggestedSeconds <= 0) return;
addCookingTimer(_cookingSuggestedSeconds, _cookingSuggestedLabel);
document.getElementById('cooking-timer-suggest').style.display = 'none';
_cookingSuggestedSeconds = 0;
}
function addCookingTimer(seconds, label) {
const id = ++_cookingTimerIdCounter;
_cookingTimers.push({ id, label, total: seconds, seconds, running: false, interval: null });
renderTimersBar();
toggleCookingTimerById(id); // auto-start
}
function removeCookingTimer(id) {
const t = _cookingTimers.find(t => t.id === id);
if (t && t.interval) clearInterval(t.interval);
_cookingTimers = _cookingTimers.filter(t => t.id !== id);
renderTimersBar();
_updateScreenFlash();
}
function toggleCookingTimerById(id) {
const t = _cookingTimers.find(t => t.id === id);
if (!t) return;
if (t.running) {
clearInterval(t.interval);
t.interval = null;
t.running = false;
} else {
t.running = true;
t.interval = setInterval(() => {
t.seconds--;
if (t.seconds === 10 && _cookingTTS) {
speakCookingStep(t('cooking.timer_warning_tts').replace('{label}', t.label));
}
if (t.seconds === 0) _cookingTimerDoneById(id);
_updateTimerCard(id);
}, 1000);
}
_updateTimerCard(id);
}
function resetCookingTimerById(id) {
const t = _cookingTimers.find(t => t.id === id);
if (!t) return;
clearInterval(t.interval);
t.interval = null;
t.running = false;
t.seconds = t.total;
_updateTimerCard(id);
}
function _cookingTimerDoneById(id) {
if (navigator.vibrate) navigator.vibrate([300, 100, 300, 100, 300]);
const timer = _cookingTimers.find(ti => ti.id === id);
if (_cookingTTS && timer) speakCookingStep(t('cooking.timer_expired_tts').replace('{label}', timer.label));
}
function _updateTimerCard(id) {
const t = _cookingTimers.find(t => t.id === id);
if (!t) return;
const card = document.getElementById(`ctimer-${id}`);
if (!card) { renderTimersBar(); return; }
const sec = t.seconds;
const dispEl = card.querySelector('.ctimer-display');
const toggleBtn = card.querySelector('.ctimer-toggle');
dispEl.textContent = _formatTimerDisplay(sec);
if (sec <= 0) {
dispEl.className = 'ctimer-display ctimer-done';
} else if (sec <= 30) {
dispEl.className = 'ctimer-display ctimer-warning';
} else {
dispEl.className = 'ctimer-display';
}
toggleBtn.textContent = t.running ? '⏸' : '▶';
toggleBtn.classList.toggle('running', t.running);
_updateScreenFlash();
}
/** Update the full-screen colour flash based on the worst active timer state. */
function _updateScreenFlash() {
const flashEl = document.getElementById('cooking-flash-overlay');
if (!flashEl) return;
let hasDone = false, hasWarning = false;
for (const t of _cookingTimers) {
if (t.seconds <= 0) { hasDone = true; break; }
if (t.seconds <= 30 && t.running) hasWarning = true;
}
if (hasDone) {
flashEl.className = 'cooking-flash-overlay flash-done';
} else if (hasWarning) {
flashEl.className = 'cooking-flash-overlay flash-warning';
} else {
flashEl.className = 'cooking-flash-overlay';
}
}
function renderTimersBar() {
const bar = document.getElementById('cooking-timers-bar');
if (!bar) return;
if (_cookingTimers.length === 0) {
bar.style.display = 'none';
bar.innerHTML = '';
return;
}
bar.style.display = 'flex';
bar.innerHTML = _cookingTimers.map(t => {
const sec = t.seconds;
const doneClass = sec <= 0 ? ' ctimer-done' : sec <= 30 ? ' ctimer-warning' : '';
const runClass = t.running ? ' running' : '';
return `<div class="cooking-timer-card" id="ctimer-${t.id}">
<span class="ctimer-label">${escapeHtml(t.label)}</span>
<span class="ctimer-display${doneClass}">${_formatTimerDisplay(sec)}</span>
<div class="ctimer-btns">
<button class="ctimer-btn ctimer-toggle${runClass}" onclick="toggleCookingTimerById(${t.id})">${t.running ? '⏸' : '▶'}</button>
<button class="ctimer-btn ctimer-reset" onclick="resetCookingTimerById(${t.id})">↩</button>
<button class="ctimer-btn ctimer-remove" onclick="removeCookingTimer(${t.id})">✕</button>
</div>
</div>`;
}).join('');
}
function clearAllCookingTimers() {
_cookingTimers.forEach(t => { if (t.interval) clearInterval(t.interval); });
_cookingTimers = [];
_cookingTimerIdCounter = 0;
_cookingSuggestedSeconds = 0;
_cookingSuggestedLabel = '';
const bar = document.getElementById('cooking-timers-bar');
if (bar) { bar.style.display = 'none'; bar.innerHTML = ''; }
_updateScreenFlash();
}
// ===== END COOKING TIMER SYSTEM =====
function toggleCookingTTS() {
_cookingTTS = !_cookingTTS;
const btn = document.getElementById('cooking-tts-btn');
btn.textContent = _cookingTTS ? '🔊' : '🔇';
if (_cookingTTS) {
const steps = _cookingRecipe?.steps || [];
const text = (steps[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
speakCookingStep(text);
}
}
function navigateCookingStep(delta) {
if (!_cookingRecipe) return;
const total = (_cookingRecipe.steps || []).length;
const next = _cookingStep + delta;
if (next < 0) return;
if (next >= total) {
// All steps done: mark all visited, announce completion, then close overlay
for (let i = 0; i < total; i++) _cookingVisited.add(i);
if (_cookingTTS) {
const doneText = t('cooking.recipe_done_tts').replace('{title}', _cookingRecipe.title || '');
speakCookingStep(doneText);
}
closeCookingMode();
return;
}
_cookingStep = next;
renderCookingStep();
if (_cookingTTS) {
const text = ((_cookingRecipe.steps || [])[_cookingStep] || '').replace(/^Passo\s*\d+\s*[:.]\s*/i, '');
speakCookingStep(text);
}
}
function cookingUseIngredient(idx, productId, location, qtyNumber, btn) {
// Reuse the same modal used in the recipe dialog
useRecipeIngredient(idx, productId, location, qtyNumber, btn);
// Mark ingredient as used so it's hidden from further steps
if (_cookingRecipe && _cookingRecipe.ingredients && _cookingRecipe.ingredients[idx]) {
_cookingRecipe.ingredients[idx].used = true;
}
setTimeout(() => renderCookingStep(), 400);
}
// ===== END COOKING MODE =====
function updateRecipeMealTitle() {
const meal = getSelectedMealType();
const mealLabels = getMealLabels();
document.getElementById('recipe-meal-title').textContent = mealLabels[meal] || t('recipes.dialog_title');
_renderMealPlanHint(meal);
_renderMealSubTypes(meal);
}
function _renderMealSubTypes(mealId) {
const container = document.getElementById('recipe-subtype-group');
if (!container) return;
const subs = getMealSubTypes()[mealId];
if (!subs) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = '';
container.innerHTML = subs.map((s, i) =>
`<label class="recipe-meal-chip recipe-subtype-chip"><input type="radio" name="recipe-subtype" value="${s.id}"${i === 0 ? ' checked' : ''}> ${s.icon} ${s.label}</label>`
).join('');
}
function getSelectedSubType() {
const checked = document.querySelector('input[name="recipe-subtype"]:checked');
return checked ? checked.value : '';
}
/** Show/hide the meal-plan badge hint + top banner in the recipe dialog. */
function onMealPlanChipChange(cb) {
const show = cb.checked;
const banner = document.getElementById('recipe-mealplan-banner');
const hint = document.getElementById('recipe-mealplan-hint');
if (banner) banner.style.display = show ? 'flex' : 'none';
if (hint) hint.style.display = show ? 'flex' : 'none';
}
function _renderMealPlanHint(mealSlot) {
const el = document.getElementById('recipe-mealplan-hint');
const banner = document.getElementById('recipe-mealplan-banner');
const chipWrap = document.getElementById('recipe-opt-mealplan-wrap');
const chipLabel = document.getElementById('recipe-opt-mealplan-label');
const chipCb = document.getElementById('recipe-opt-mealplan');
// mealSlot = 'pranzo' or 'cena' (from getMealType/getSelectedMealType)
const typeId = (mealSlot === 'pranzo' || mealSlot === 'cena')
? getTodayMealPlanType(mealSlot)
: null;
if (!typeId || typeId === 'libero') {
if (el) el.style.display = 'none';
if (banner) banner.style.display = 'none';
if (chipWrap) chipWrap.style.display = 'none';
return;
}
const mpt = getMealPlanTypeMap()[typeId];
if (!mpt) {
if (el) el.style.display = 'none';
if (banner) banner.style.display = 'none';
if (chipWrap) chipWrap.style.display = 'none';
return;
}
if (el) {
el.innerHTML = `<span class="mplan-hint-badge">${mpt.icon} ${mpt.label}</span> <span class="mplan-hint-label">${t('meal_plan.suggested_by')}</span>`;
el.style.display = 'flex';
}
if (banner) {
const slotLabel = mealSlot === 'pranzo' ? '🌤️ ' + t('meal_types.pranzo') : '🌙 ' + t('meal_types.cena');
banner.innerHTML = `<span style="opacity:0.75;font-weight:500">${slotLabel}</span><span style="opacity:0.45">·</span><span>${mpt.icon} ${mpt.label}</span>`;
banner.style.display = 'flex';
}
// Show the meal-plan chip (active by default, user can uncheck to ignore the plan)
if (chipWrap) {
chipWrap.style.display = '';
if (chipLabel) chipLabel.textContent = `${mpt.icon} ${mpt.label}`;
if (chipCb) chipCb.checked = true;
}
}
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;
const meal = getSelectedMealType();
_recipeVariationCount[meal] = (_recipeVariationCount[meal] || 0) + 1;
document.getElementById('recipe-result').style.display = 'none';
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
}
async function generateRecipe() {
if (!_requireGemini()) return;
const meal = getSelectedMealType();
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');
const mealPlanCb = document.getElementById('recipe-opt-mealplan');
const mealPlanChipActive = !mealPlanChipWrap || mealPlanChipWrap.style.display === 'none' || (mealPlanCb && mealPlanCb.checked);
const mealPlanType = mealPlanChipActive && (meal === 'pranzo' || meal === 'cena')
? (getTodayMealPlanType(meal) || null)
: null;
// Gather active options from checkboxes
const options = [];
const optMap = {
'recipe-opt-veloce': 'veloce',
'recipe-opt-pocafame': 'pocafame',
'recipe-opt-scadenze': 'scadenze',
'recipe-opt-healthy': 'salutare',
'recipe-opt-opened': 'opened',
'recipe-opt-zerowaste': 'zerowaste'
};
Object.entries(optMap).forEach(([id, key]) => {
const cb = document.getElementById(id);
if (cb && cb.checked) options.push(key);
});
document.getElementById('recipe-ask').style.display = 'none';
document.getElementById('recipe-loading').style.display = '';
document.getElementById('recipe-result').style.display = 'none';
const loadingMsg = document.getElementById('recipe-loading-msg');
try {
const payload = {
meal,
persons,
lang: _currentLang,
sub_type: getMealSubTypes()[meal] ? getSelectedSubType() : '',
options,
appliances: settings.appliances || [],
dietary_restrictions: settings.dietary_restrictions || '',
today_recipes: [...new Set([...await getTodayRecipeTitles(), ..._generatedTodayTitles])],
meal_plan_type: mealPlanType,
variation: _recipeVariationCount[meal] || 0,
rejected_ingredients: _rejectedRecipeIngredients,
};
const response = await fetch('api/index.php?action=generate_recipe_stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
if (data.error === 'no_api_key') {
showToast(t('error.no_api_key'), 'warning');
} else {
showToast(data.error || t('recipes.generate_error'), 'error');
}
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let recipe = null;
let errorEvent = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const event = JSON.parse(line.slice(6));
if (event.type === 'status' && loadingMsg) {
loadingMsg.textContent = event.message;
} else if (event.type === 'recipe') {
recipe = event.recipe;
} else if (event.type === 'error') {
errorEvent = event;
}
} catch (_) { /* ignore malformed SSE lines */ }
}
}
if (recipe) {
renderRecipe(recipe);
if (recipe.title) _generatedTodayTitles.push(recipe.title);
await saveRecipeToArchive(recipe);
_cachedRecipe = { meal, recipe };
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = '';
} else {
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
if (errorEvent) {
if (errorEvent.error === 'no_api_key') {
showToast(t('error.no_api_key'), 'warning');
} else {
const detail = errorEvent.detail ? ` (${errorEvent.detail})` : '';
showToast((errorEvent.error || t('recipes.generate_error')) + detail, 'error');
}
} else {
showToast(t('error.connection'), 'error');
}
}
} catch (err) {
console.error('Recipe error:', err);
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
showToast(t('error.connection'), 'error');
}
}
// ===== GEMINI CHAT =====
let chatHistory = [];
let chatInventoryContext = null;
let _chatSavedCount = 0; // track how many messages already saved to DB
function initChat() {
// Load chat history from DB
api('chat_list').then(res => {
if (res.success && res.messages && res.messages.length > 0) {
chatHistory = res.messages.map(m => ({ role: m.role, text: m.text }));
_chatSavedCount = chatHistory.length;
renderChatHistory();
} else {
_chatSavedCount = 0;
}
}).catch(() => { _chatSavedCount = 0; });
// Always reload fresh inventory context
loadChatContext();
// Focus input
setTimeout(() => {
const input = document.getElementById('chat-input');
if (input) input.focus();
}, 300);
}
async function loadChatContext() {
try {
const data = await api('inventory_list');
chatInventoryContext = data.inventory || [];
} catch(e) { chatInventoryContext = []; }
}
function sendChatSuggestion(text) {
document.getElementById('chat-input').value = text;
sendChatMessage();
}
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
// Hide welcome if first message
const welcome = document.querySelector('.chat-welcome');
if (welcome) welcome.style.display = 'none';
// Add user message
chatHistory.push({ role: 'user', text });
appendChatBubble('user', text);
saveChatHistory();
// Show typing indicator
const typingEl = appendChatBubble('gemini', '<div class="chat-typing"><span></span><span></span><span></span></div>', true);
scrollChatBottom();
// Disable send
const btn = document.getElementById('btn-chat-send');
btn.disabled = true;
try {
const settings = getSettings();
const result = await api('gemini_chat', {}, 'POST', {
message: text,
history: chatHistory.slice(0, -1).slice(-20), // last 20 messages for context
appliances: settings.appliances || [],
dietary_restrictions: settings.dietary_restrictions || ''
});
// Remove typing indicator
typingEl.remove();
if (result.success) {
chatHistory.push({ role: 'gemini', text: result.reply });
appendChatBubble('gemini', formatChatReply(result.reply));
} else {
const errMsg = result.error === 'no_api_key' ? 'Configura la chiave API Gemini nelle impostazioni.' : (result.error || 'Errore nella risposta');
appendChatBubble('gemini', `⚠️ ${escapeHtml(errMsg)}`);
}
} catch(err) {
typingEl.remove();
appendChatBubble('gemini', '⚠️ ' + t('error.connection'));
}
btn.disabled = false;
saveChatHistory();
scrollChatBottom();
}
function appendChatBubble(role, html, isRaw = false) {
const container = document.getElementById('chat-messages');
const bubble = document.createElement('div');
bubble.className = `chat-bubble chat-${role}`;
if (isRaw) {
bubble.innerHTML = html;
} else if (role === 'user') {
bubble.textContent = html;
} else {
bubble.innerHTML = html;
}
container.appendChild(bubble);
scrollChatBottom();
return bubble;
}
function formatChatReply(text) {
// Convert markdown-like formatting
let html = escapeHtml(text);
// Bold **text**
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic *text*
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
// Numbered lists
html = html.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>');
// Line breaks
html = html.replace(/\n/g, '<br>');
// Clean up consecutive ul tags
html = html.replace(/<\/ul>\s*<br>\s*<ul>/g, '');
return html;
}
function renderChatHistory() {
const container = document.getElementById('chat-messages');
if (chatHistory.length === 0) return;
// Hide welcome
const welcome = container.querySelector('.chat-welcome');
if (welcome) welcome.style.display = 'none';
chatHistory.forEach(msg => {
if (msg.role === 'user') {
appendChatBubble('user', msg.text);
} else {
appendChatBubble('gemini', formatChatReply(msg.text));
}
});
scrollChatBottom();
}
function scrollChatBottom() {
const container = document.getElementById('chat-messages');
setTimeout(() => container.scrollTop = container.scrollHeight, 50);
}
function clearChat() {
chatHistory = [];
api('chat_clear', {}, 'POST').catch(() => {});
const container = document.getElementById('chat-messages');
container.innerHTML = `
<div class="chat-welcome">
<svg class="gemini-icon-lg" viewBox="0 0 24 24" width="48" height="48" fill="#6366f1"><path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z"/></svg>
<h3>${t('chat.welcome')}</h3>
<p>${t('chat.welcome_desc')}</p>
<div class="chat-suggestions">
<button class="chat-suggestion" onclick="sendChatSuggestion(t('chat.suggestion_snack_text'))">${t('chat.suggestion_snack')}</button>
<button class="chat-suggestion" onclick="sendChatSuggestion(t('chat.suggestion_juice_text'))">${t('chat.suggestion_juice')}</button>
<button class="chat-suggestion" onclick="sendChatSuggestion(t('chat.suggestion_light_text'))">${t('chat.suggestion_light')}</button>
<button class="chat-suggestion" onclick="sendChatSuggestion(t('chat.suggestion_expiry_text'))">${t('chat.suggestion_expiry')}</button>
</div>
</div>
`;
showToast(t('chat.cleared'), 'success');
}
function saveChatHistory() {
// Keep last 50 messages max
if (chatHistory.length > 50) {
const trimmed = chatHistory.length - 50;
chatHistory = chatHistory.slice(-50);
_chatSavedCount = Math.max(0, _chatSavedCount - trimmed);
}
// Only save messages that haven't been saved yet (prevent duplicates)
const unsaved = chatHistory.slice(_chatSavedCount);
if (unsaved.length === 0) return;
api('chat_save', {}, 'POST', { messages: unsaved }).then(() => {
_chatSavedCount = chatHistory.length;
}).catch(() => {});
}
// ===== SCREENSAVER & INACTIVITY AUTO-REFRESH =====
let _inactivityTimer = null;
let _screensaverActive = false;
let _screensaverClockInterval = null;
let _screensaverFactInterval = null;
let _screensaverData = null; // cached data for fact generation
const SCREENSAVER_FACT_DURATION = 5 * 60 * 1000; // 5 minutes per fact
const INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5 minutes
function resetInactivityTimer() {
if (_screensaverActive) return; // don't reset while screensaver is showing
clearTimeout(_inactivityTimer);
_inactivityTimer = setTimeout(activateScreensaver, INACTIVITY_TIMEOUT);
}
function activateScreensaver() {
if (_screensaverActive) return;
if (document.body.classList.contains('cooking-mode-active')) return;
_screensaverActive = true;
const overlay = document.getElementById('screensaver');
overlay.style.display = 'flex';
// Fade in
requestAnimationFrame(() => overlay.classList.add('visible'));
updateScreensaverClock();
_screensaverClockInterval = setInterval(updateScreensaverClock, 1000);
// Load data and start facts
loadScreensaverData().then(() => {
showNextScreensaverFact();
_screensaverFactInterval = setInterval(showNextScreensaverFact, SCREENSAVER_FACT_DURATION);
});
}
function updateScreensaverClock() {
const now = new Date();
const _ssLocale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT';
const time = now.toLocaleTimeString(_ssLocale, { hour: '2-digit', minute: '2-digit' });
const date = now.toLocaleDateString(_ssLocale, { weekday: 'long', day: 'numeric', month: 'long' });
const el = document.getElementById('screensaver-clock');
if (el) el.innerHTML = `${time}<div class="screensaver-date">${date}</div>`;
updateScreensaverMealPlan();
}
/** Show/hide the planned meal type badge on the screensaver based on current time slot. */
function updateScreensaverMealPlan() {
const el = document.getElementById('screensaver-mealplan');
if (!el) return;
const s = getSettings();
if (s.meal_plan_enabled === false) { el.style.display = 'none'; return; }
const hour = new Date().getHours();
// Before 15:00 show pranzo, from 15:00 onwards show cena
const slot = hour < 15 ? 'pranzo' : 'cena';
const typeId = getTodayMealPlanType(slot);
if (!typeId || typeId === 'libero') { el.style.display = 'none'; return; }
const mpt = getMealPlanTypeMap()[typeId];
if (!mpt) { el.style.display = 'none'; return; }
const slotLabel = slot === 'pranzo' ? '🌤️ ' + t('meal_types.pranzo') : '🌙 ' + t('meal_types.cena');
el.innerHTML = `<span class="screensaver-mealplan-badge">${slotLabel} · ${mpt.icon} ${mpt.label}</span>`;
el.style.display = 'block';
}
function dismissScreensaver(targetPage) {
if (!_screensaverActive) return;
clearInterval(_screensaverClockInterval);
clearInterval(_screensaverFactInterval);
const overlay = document.getElementById('screensaver');
overlay.classList.remove('visible');
setTimeout(() => {
overlay.style.display = 'none';
_screensaverActive = false;
_screensaverData = null;
if (targetPage) {
showPage(targetPage);
} else {
refreshCurrentPage();
}
resetInactivityTimer();
}, 400);
}
// Load all data needed for screensaver facts
async function loadScreensaverData() {
try {
const [statsRes, invRes, bringRes] = await Promise.all([
api('stats'),
api('inventory_list'),
api('bring_list').catch(() => null)
]);
_screensaverData = {
stats: statsRes,
inventory: invRes.inventory || [],
shopping: bringRes && bringRes.success ? (bringRes.purchase || []) : []
};
} catch (e) {
_screensaverData = { stats: {}, inventory: [], shopping: [] };
}
}
// Show next random fact with fade in/out
function showNextScreensaverFact() {
const el = document.getElementById('screensaver-fact');
if (!el) return;
el.classList.remove('visible');
setTimeout(() => {
el.textContent = generateScreensaverFact();
el.classList.add('visible');
}, 1600);
}
// Generate a dynamic fact from available data
function generateScreensaverFact() {
const d = _screensaverData || { stats: {}, inventory: [], shopping: [] };
const inv = d.inventory;
const stats = d.stats;
const shop = d.shopping;
const now = new Date();
const hour = now.getHours();
// Pre-compute useful data
const expired = stats.expired || [];
const expiringSoon = stats.expiring_soon || [];
const totalProducts = stats.total_products || inv.length;
const totalItems = stats.total_items || 0;
const byLocation = {};
const byCategory = {};
const withExpiry = [];
const noExpiry = [];
const expiringThisWeek = [];
const expiringThisMonth = [];
const inFreezer = [];
const inFrigo = [];
const inDispensa = [];
for (const item of inv) {
// by location
const loc = item.location || 'altro';
if (!byLocation[loc]) byLocation[loc] = [];
byLocation[loc].push(item);
if (loc === 'freezer') inFreezer.push(item);
else if (loc === 'frigo') inFrigo.push(item);
else if (loc === 'dispensa') inDispensa.push(item);
// by category
const cat = mapToLocalCategory(item.category, item.name);
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(item);
// expiry
if (item.expiry_date) {
withExpiry.push(item);
const days = daysUntilExpiry(item.expiry_date);
if (days >= 0 && days <= 7) expiringThisWeek.push(item);
if (days >= 0 && days <= 30) expiringThisMonth.push(item);
} else {
noExpiry.push(item);
}
}
// Greeting based on time
const greeting = hour < 12 ? 'Buongiorno' : hour < 18 ? 'Buon pomeriggio' : 'Buonasera';
// Random item picker
const rItem = (arr) => arr.length ? arr[Math.floor(Math.random() * arr.length)] : null;
// All fact generators
const facts = [];
// --- Expired items facts ---
if (expired.length > 0) {
facts.push(() => `Hai ${expired.length} ${expired.length === 1 ? 'prodotto scaduto' : 'prodotti scaduti'} in dispensa. Controlla!`);
facts.push(() => {
const names = expired.slice(0, 3).map(i => i.name);
return `Prodotti scaduti: ${names.join(', ')}${expired.length > 3 ? ` e altri ${expired.length - 3}` : ''}`;
});
const freezerExpired = expired.filter(i => i.location === 'freezer');
if (freezerExpired.length > 0) {
facts.push(() => {
const item = rItem(freezerExpired);
const safety = getExpiredSafety(item, Math.abs(daysUntilExpiry(item.expiry_date)));
if (safety.level === 'ok' || safety.level === 'warning') {
return `${item.name} è scaduto, ma essendo in freezer potrebbe essere ancora buono! Controlla.`;
}
return `${item.name} in freezer è scaduto da troppo tempo. Meglio buttarlo.`;
});
}
const frigoExpired = expired.filter(i => i.location === 'frigo');
if (frigoExpired.length > 0) {
facts.push(() => `Hai ${frigoExpired.length} ${frigoExpired.length === 1 ? 'prodotto scaduto' : 'prodotti scaduti'} in frigo!`);
}
}
// --- Expiring soon facts ---
if (expiringSoon.length > 0) {
facts.push(() => {
const item = expiringSoon[0];
const days = daysUntilExpiry(item.expiry_date);
if (days === 0) return `${item.name} scade oggi! Usalo subito.`;
if (days === 1) return `${item.name} scade domani. Pensaci!`;
return `${item.name} scade tra ${days} giorni.`;
});
if (expiringSoon.length > 1) {
facts.push(() => `Hai ${expiringSoon.length} prodotti in scadenza ravvicinata.`);
}
}
if (expiringThisWeek.length > 0) {
facts.push(() => `Questa settimana scadono ${expiringThisWeek.length} prodotti. Pianifica i pasti di conseguenza!`);
facts.push(() => {
const item = rItem(expiringThisWeek);
const days = daysUntilExpiry(item.expiry_date);
const locLabel = LOCATIONS[item.location]?.label || item.location;
return `${item.name} (${locLabel}) scade tra ${days} ${days === 1 ? 'giorno' : 'giorni'}.`;
});
}
if (expiringThisMonth.length > 0) {
facts.push(() => `In questo mese scadranno ${expiringThisMonth.length} prodotti.`);
}
// --- Shopping list facts ---
if (shop.length > 0) {
facts.push(() => `Hai ${shop.length} ${shop.length === 1 ? 'prodotto' : 'prodotti'} nella lista della spesa.`);
facts.push(() => {
const names = shop.slice(0, 4).map(i => i.name);
return `Nella spesa: ${names.join(', ')}${shop.length > 4 ? '...' : ''}`;
});
}
if (shop.length === 0) {
facts.push(() => `La lista della spesa è vuota. Tutto a posto!`);
}
// --- Location-based facts ---
if (inFrigo.length > 0) {
facts.push(() => `Hai ${inFrigo.length} prodotti in frigo.`);
facts.push(() => {
const item = rItem(inFrigo);
return `In frigo c'è: ${item.name}${item.brand ? ' (' + item.brand + ')' : ''}.`;
});
}
if (inFreezer.length > 0) {
facts.push(() => `Hai ${inFreezer.length} prodotti nel freezer.`);
facts.push(() => {
const item = rItem(inFreezer);
return `Nel freezer c'è: ${item.name}. Non dimenticartelo!`;
});
}
if (inDispensa.length > 0) {
facts.push(() => `In dispensa ci sono ${inDispensa.length} prodotti.`);
}
// --- Category-based facts ---
const catEntries = Object.entries(byCategory);
if (catEntries.length > 0) {
facts.push(() => {
const sorted = catEntries.sort((a, b) => b[1].length - a[1].length);
const top = sorted[0];
const catLabel = top[0];
const icon = CATEGORY_ICONS[catLabel] || '📦';
return `La categoria più presente è ${icon} ${catLabel} con ${top[1].length} prodotti.`;
});
if (byCategory['carne'] && byCategory['carne'].length > 0) {
facts.push(() => `Hai ${byCategory['carne'].length} prodotti di carne. 🥩`);
}
if (byCategory['latticini'] && byCategory['latticini'].length > 0) {
facts.push(() => `Hai ${byCategory['latticini'].length} latticini in casa. 🥛`);
}
if (byCategory['verdura'] && byCategory['verdura'].length > 0) {
facts.push(() => `Hai ${byCategory['verdura'].length} tipi di verdura. Ottimo per la salute! 🥬`);
}
if (byCategory['frutta'] && byCategory['frutta'].length > 0) {
facts.push(() => `Hai ${byCategory['frutta'].length} tipi di frutta. 🍎`);
}
if (byCategory['bevande'] && byCategory['bevande'].length > 0) {
facts.push(() => `Hai ${byCategory['bevande'].length} bevande disponibili. 🥤`);
}
if (byCategory['surgelati'] && byCategory['surgelati'].length > 0) {
facts.push(() => `Hai ${byCategory['surgelati'].length} surgelati nel freezer. ❄️`);
}
if (byCategory['pasta'] && byCategory['pasta'].length > 0) {
facts.push(() => `Hai ${byCategory['pasta'].length} tipi di pasta. 🍝 Che ne dici di una carbonara?`);
}
if (byCategory['conserve'] && byCategory['conserve'].length > 0) {
facts.push(() => `Hai ${byCategory['conserve'].length} conserve in dispensa. 🥫`);
}
if (byCategory['snack'] && byCategory['snack'].length > 0) {
facts.push(() => `Hai ${byCategory['snack'].length} snack. Resisti alla tentazione! 🍪`);
}
if (byCategory['condimenti'] && byCategory['condimenti'].length > 0) {
facts.push(() => `Hai ${byCategory['condimenti'].length} condimenti a disposizione. 🧂`);
}
}
// --- General inventory facts ---
if (inv.length > 0) {
facts.push(() => `Hai ${totalProducts} prodotti diversi in casa per un totale di ${Math.round(totalItems)} pezzi.`);
facts.push(() => {
const item = rItem(inv);
return `Lo sapevi? Hai ${item.name} in ${LOCATIONS[item.location]?.label || item.location}.`;
});
facts.push(() => {
const item = rItem(inv);
const qty = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
return `${item.name}: ne hai ${qty}.`;
});
}
if (noExpiry.length > 0) {
facts.push(() => `${noExpiry.length} prodotti non hanno una data di scadenza impostata.`);
}
if (withExpiry.length > 0) {
// Find the one expiring furthest away
const furthest = withExpiry.reduce((best, item) => {
const d = daysUntilExpiry(item.expiry_date);
return d > (best.d || 0) ? { item, d } : best;
}, { d: 0 });
if (furthest.item && furthest.d > 30) {
facts.push(() => `Il prodotto con scadenza più lontana è ${furthest.item.name}: ${Math.round(furthest.d / 30)} mesi.`);
}
}
// --- Quantity-based facts ---
const highQtyItems = inv.filter(i => parseFloat(i.quantity) >= 5);
if (highQtyItems.length > 0) {
facts.push(() => {
const item = rItem(highQtyItems);
const qty = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit);
return `Hai una bella scorta di ${item.name}: ${qty}!`;
});
}
const lowQtyItems = inv.filter(i => parseFloat(i.quantity) <= 1 && parseFloat(i.quantity) > 0);
if (lowQtyItems.length > 0) {
facts.push(() => {
const item = rItem(lowQtyItems);
return `${item.name} sta per finire. Aggiungilo alla spesa?`;
});
facts.push(() => `Ci sono ${lowQtyItems.length} prodotti quasi finiti.`);
}
// --- Time-of-day greetings & suggestions ---
facts.push(() => `${greeting}! Se vuoi che ti preparo una ricetta, tocca qui.`);
facts.push(() => `${greeting}! La tua dispensa è sotto controllo. 😊`);
if (hour >= 6 && hour < 10) {
facts.push(() => `Buongiorno! Pronto per la colazione? ☕`);
if (byCategory['pane']) facts.push(() => `Buongiorno! Hai del pane per la colazione. 🍞`);
if (byCategory['latticini']) facts.push(() => `C'è del latte in frigo per il cappuccino? ☕🥛`);
}
if (hour >= 11 && hour < 14) {
facts.push(() => `È quasi ora di pranzo! Cosa cuciniamo? 🍽️`);
if (byCategory['pasta']) facts.push(() => `Ora di pranzo… Un bel piatto di pasta? 🍝`);
}
if (hour >= 17 && hour < 21) {
facts.push(() => `Buona sera! Hai pensato alla cena? 🍽️`);
if (byCategory['carne']) facts.push(() => `Per cena potresti usare la carne che hai. 🥩`);
if (byCategory['pesce']) facts.push(() => `Che ne dici di pesce per cena? 🐟`);
}
if (hour >= 21 || hour < 6) {
facts.push(() => `Buonanotte! Domani controlla le scadenze. 🌙`);
}
// --- Weekly stats ---
const recentIn = stats.recent_in || 0;
const recentOut = stats.recent_out || 0;
if (recentIn > 0) {
facts.push(() => `Questa settimana hai aggiunto ${recentIn} prodotti.`);
}
if (recentOut > 0) {
facts.push(() => `Questa settimana hai consumato ${recentOut} prodotti.`);
}
if (recentIn > 0 && recentOut > 0) {
facts.push(() => `Bilancio settimanale: +${recentIn} entrati, -${recentOut} usciti.`);
}
// --- Tips & curiosità (statici ma ruotano) ---
facts.push(() => `💡 Lo sapevi? I prodotti in freezer durano molto più a lungo della data di scadenza.`);
facts.push(() => `💡 Il pane congelato mantiene la fragranza per settimane.`);
facts.push(() => `💡 Le uova si conservano fino a 3-4 settimane dopo la data preferita.`);
facts.push(() => `💡 Lo yogurt chiuso in frigo dura spesso 1-2 settimane oltre la scadenza.`);
facts.push(() => `💡 Per evitare sprechi, usa prima i prodotti con scadenza più vicina.`);
facts.push(() => `💡 La carne in freezer può durare fino a 6 mesi senza problemi.`);
facts.push(() => `💡 Le verdure fresche durano di più se conservate nel cassetto del frigo.`);
facts.push(() => `💡 Controlla regolarmente la dispensa per evitare doppioni nella spesa.`);
facts.push(() => `💡 I latticini vanno conservati nella parte più fredda del frigo.`);
facts.push(() => `💡 Non ricongelare mai un alimento già scongelato. Cucinalo subito!`);
facts.push(() => `💡 Un frigo ordinato ti fa risparmiare tempo e denaro.`);
facts.push(() => `💡 Le conserve aperte vanno in frigo e consumate in pochi giorni.`);
// --- Brand-based facts ---
const brands = inv.filter(i => i.brand).map(i => i.brand);
if (brands.length > 0) {
const brandCount = {};
brands.forEach(b => { brandCount[b] = (brandCount[b] || 0) + 1; });
const topBrand = Object.entries(brandCount).sort((a, b) => b[1] - a[1])[0];
facts.push(() => `Il marca più presente nella tua dispensa è ${topBrand[0]} con ${topBrand[1]} prodotti.`);
}
// --- Specific food combo facts ---
if (byCategory['pasta'] && byCategory['condimenti']) {
facts.push(() => `Hai pasta e condimenti: sei pronto per un primo piatto! 🍝`);
}
if (byCategory['pane'] && byCategory['carne']) {
facts.push(() => `Pane e carne: un panino veloce è sempre una buona idea! 🥪`);
}
if (byCategory['verdura'] && byCategory['carne']) {
facts.push(() => `Verdura e carne: hai tutto per un piatto equilibrato! 🥗🥩`);
}
// --- Empty states ---
if (inv.length === 0) {
facts.push(() => `La dispensa è vuota! Fai una bella spesa. 🛒`);
facts.push(() => `Nessun prodotto registrato. Scansiona qualcosa per iniziare!`);
}
// --- Location distribution ---
const locCount = Object.keys(byLocation).length;
if (locCount > 1) {
facts.push(() => {
const parts = Object.entries(byLocation).map(([loc, items]) =>
`${LOCATIONS[loc]?.icon || '📦'} ${items.length}`
);
return `Distribuzione: ${parts.join(' · ')}`;
});
}
// --- Anti-waste knowledge facts ---
const awFacts = _awGetFacts();
for (const f of awFacts) { facts.push(() => f); }
// Pick a random fact
if (facts.length === 0) {
return `${greeting}! La tua Dispensa ti aspetta.`;
}
return facts[Math.floor(Math.random() * facts.length)]();
}
// ===== SPESA MODE (long-press camera for continuous scanning) =====
let _spesaMode = false;
let _longPressTimer = null;
let _spesaSession = []; // { name, qty, unit } per ogni prodotto aggiunto
function initSpesaMode() {
const btn = document.getElementById('btn-header-scan');
if (!btn) return;
btn.addEventListener('pointerdown', (e) => {
_longPressTimer = setTimeout(() => {
_longPressTimer = null;
startSpesaMode();
}, 600);
});
btn.addEventListener('pointerup', () => {
if (_longPressTimer) {
clearTimeout(_longPressTimer);
_longPressTimer = null;
// Short press — normal scan
showPage('scan');
}
});
btn.addEventListener('pointerleave', () => {
if (_longPressTimer) {
clearTimeout(_longPressTimer);
_longPressTimer = null;
}
});
}
function startSpesaMode() {
_spesaMode = true;
_spesaSession = [];
showToast('🛒 Modalità Spesa attivata!', 'success');
showPage('scan');
updateSpesaBanner();
}
function endSpesaMode() {
_spesaMode = false;
updateSpesaBanner();
stopScanner();
showPage('dashboard');
}
function updateSpesaBanner() {
const banner = document.getElementById('spesa-mode-banner');
if (!banner) return;
banner.style.display = _spesaMode ? 'flex' : 'none';
const statEl = banner.querySelector('.spesa-stat');
if (statEl) statEl.textContent = _spesaBannerStat();
}
// Called after successful add — returns true if spesa mode handled navigation
function spesaModeAfterAdd() {
if (!_spesaMode) return false;
// Track this product in the session
if (currentProduct) {
_spesaSession.push({ name: currentProduct.name, category: currentProduct.category || '' });
updateSpesaBanner();
}
showPage('scan');
return true;
}
function _spesaBannerStat() {
const n = _spesaSession.length;
if (n === 0) return t('shopping.session_empty');
const cats = {};
_spesaSession.forEach(p => { const c = p.category || 'altro'; cats[c] = (cats[c]||0)+1; });
const topCat = Object.entries(cats).sort((a,b)=>b[1]-a[1])[0];
const names = _spesaSession.map(p => p.name);
const unique = [...new Set(names)];
const dupes = names.length - unique.length;
const phrases = [
n === 1 ? t('kiosk_session.first_item').replace('{name}', _spesaSession[0].name) : null,
n >= 2 && n < 5 ? t('kiosk_session.items_two_four').replace('{n}', n) : null,
n >= 5 && n < 10 ? t('kiosk_session.items_five_nine').replace('{n}', n) : null,
n >= 10 && n < 20 ? t('kiosk_session.items_ten_twenty').replace('{n}', n) : null,
n >= 20 ? t('kiosk_session.items_twenty_plus').replace('{n}', n) : null,
dupes > 0 ? (dupes === 1 ? t('kiosk_session.duplicates_one') : t('kiosk_session.duplicates_many').replace('{n}', dupes)) : null,
topCat && topCat[1] > 1 ? t('kiosk_session.top_category').replace('{cat}', topCat[0]).replace('{count}', topCat[1]) : null,
].filter(Boolean);
return phrases[n % phrases.length] || t('kiosk_session.items_fallback').replace('{n}', n).replace('{plural}', n===1?'o':'i');
}
function _initScreensaverShortcutBtn(btnId, targetPage, longPressFn) {
const btn = document.getElementById(btnId);
if (!btn) return;
let ssLongPress = null;
btn.addEventListener('pointerdown', (e) => {
e.stopPropagation();
if (longPressFn) {
ssLongPress = setTimeout(() => {
ssLongPress = null;
dismissScreensaver(targetPage);
setTimeout(longPressFn, 500);
}, 600);
}
});
btn.addEventListener('pointerup', (e) => {
e.stopPropagation();
if (longPressFn && ssLongPress) {
clearTimeout(ssLongPress);
ssLongPress = null;
}
dismissScreensaver(targetPage);
});
btn.addEventListener('pointerleave', (e) => {
e.stopPropagation();
if (ssLongPress) {
clearTimeout(ssLongPress);
ssLongPress = null;
}
});
['click', 'touchstart', 'touchend'].forEach(evt => {
btn.addEventListener(evt, (e) => e.stopPropagation(), { passive: false });
});
}
function initScreensaverShortcuts() {
_initScreensaverShortcutBtn('screensaver-scan-btn', 'scan', () => startSpesaMode());
_initScreensaverShortcutBtn('screensaver-recipe-btn', 'recipe', null);
}
function initInactivityWatcher() {
const s = getSettings();
if (!s.screensaver_enabled) return; // disabled by default
const events = ['pointerdown', 'pointermove', 'keydown', 'scroll', 'touchstart'];
events.forEach(evt => {
document.addEventListener(evt, () => {
if (_screensaverActive) {
dismissScreensaver();
} else {
resetInactivityTimer();
}
}, { passive: true });
});
resetInactivityTimer();
}
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
// Load translations first, then initialize the app
loadTranslations(_currentLang).then(() => {
_initApp();
}).catch(() => {
_initApp(); // fallback: initialize even if translations fail
});
});
// ===== SETUP WIZARD =====
let _setupStep = 0;
let _setupPendingSteps = [];
const _setupData = { lang: _currentLang, gemini_key: '', bring_email: '', bring_password: '' };
/**
* Returns indices of setup steps that still need configuration.
* Accepts optional serverSettings fetched from the API so server-side
* credentials (stored in .env) are also considered.
*/
function _getMissingSetupSteps(serverSettings) {
const missing = [];
const s = getSettings();
const srv = serverSettings || {};
const setupDone = localStorage.getItem('evershelf_setup_done');
// Step 0 — language: missing only if never set at all (fresh install)
if (!localStorage.getItem('evershelf_lang') && !setupDone) {
missing.push(0);
}
// Steps 1 & 2 only show on first run (before setup is completed/skipped)
if (!setupDone) {
// Step 1 — Gemini API key (check both localStorage and server .env)
if (!s.gemini_key && !srv.gemini_key_set) missing.push(1);
// Step 2 — Bring! credentials (check both localStorage and server .env)
if ((!s.bring_email && !srv.bring_email) || (!s.bring_password && !srv.bring_password_set)) missing.push(2);
}
// Note: step 3 (done screen) gets appended automatically when there are missing steps
return missing;
}
function _setupSteps() {
return [
{
title: '🌐 ' + t('settings.language.label'),
desc: t('settings.language.hint'),
render: () => {
let html = '<div class="setup-lang-grid">';
for (const [code, name] of Object.entries(_SUPPORTED_LANGS)) {
const sel = code === _setupData.lang ? ' selected' : '';
html += `<button class="setup-lang-btn${sel}" onclick="_setupSelectLang('${code}')">${name}</button>`;
}
html += '</div>';
return html;
}
},
{
title: '🤖 Google Gemini AI',
desc: t('settings.gemini.hint'),
render: () => `
<div class="form-group">
<label>${t('settings.gemini.key_label')}</label>
<input type="text" id="setup-gemini-key" class="form-input" placeholder="AIza..." value="${_setupData.gemini_key}">
<p style="color:#999;font-size:0.8rem;margin-top:8px">
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">→ Get a free API key from Google AI Studio</a>
</p>
</div>
<span class="setup-skip-link" onclick="_setupSkipStep()">${t('btn.cancel')}${_currentLang === 'it' ? 'configura dopo' : 'configure later'}</span>
`
},
{
title: '🛒 Bring! Shopping List',
desc: t('settings.bring.hint'),
render: () => `
<div class="form-group">
<label>${t('settings.bring.email_label')}</label>
<input type="email" id="setup-bring-email" class="form-input" placeholder="email@example.com" value="${_setupData.bring_email}">
</div>
<div class="form-group">
<label>${t('settings.bring.password_label')}</label>
<input type="password" id="setup-bring-password" class="form-input" placeholder="Password" value="${_setupData.bring_password}">
</div>
<span class="setup-skip-link" onclick="_setupSkipStep()">${t('btn.cancel')}${_currentLang === 'it' ? 'configura dopo' : 'configure later'}</span>
`
},
{
title: '✅ ' + (_currentLang === 'it' ? 'Tutto pronto!' : _currentLang === 'de' ? 'Alles bereit!' : 'All set!'),
desc: _currentLang === 'it' ? 'La configurazione è completata. Puoi sempre modificare queste impostazioni dalla pagina Configurazione.'
: _currentLang === 'de' ? 'Die Konfiguration ist abgeschlossen. Du kannst diese Einstellungen jederzeit ändern.'
: 'Setup is complete. You can always change these settings from the Settings page.',
render: () => {
let summary = '<div style="text-align:center;font-size:2.5rem;margin:12px 0">🎉</div>';
return summary;
}
}
];
}
function showSetupWizard(pendingSteps) {
_setupPendingSteps = pendingSteps || _getMissingSetupSteps();
if (_setupPendingSteps.length === 0) return;
// Append the "done" step (3) at the end
_setupPendingSteps.push(3);
_setupStep = 0;
// Pre-fill _setupData from existing settings so we don't lose them
const s = getSettings();
if (s.gemini_key) _setupData.gemini_key = s.gemini_key;
if (s.bring_email) _setupData.bring_email = s.bring_email;
if (s.bring_password) _setupData.bring_password = s.bring_password;
document.getElementById('setup-wizard').style.display = '';
_renderSetupStep();
}
function _renderSetupStep() {
const allSteps = _setupSteps();
const totalPending = _setupPendingSteps.length;
const realIndex = _setupPendingSteps[_setupStep];
const step = allSteps[realIndex];
// Progress dots (based on pending steps only)
const dotsHtml = _setupPendingSteps.map((_, i) => {
let cls = 'setup-dot';
if (i < _setupStep) cls += ' done';
if (i === _setupStep) cls += ' active';
return `<div class="${cls}"></div>`;
}).join('');
document.getElementById('setup-progress').innerHTML = dotsHtml;
// Body
document.getElementById('setup-body').innerHTML = `<h3>${step.title}</h3><p>${step.desc}</p>${step.render()}`;
// Buttons
const prevBtn = document.getElementById('setup-prev');
const nextBtn = document.getElementById('setup-next');
prevBtn.style.display = _setupStep > 0 ? '' : 'none';
prevBtn.textContent = t('btn.back');
if (_setupStep === totalPending - 1) {
nextBtn.textContent = _currentLang === 'it' ? '🚀 Inizia!' : _currentLang === 'de' ? '🚀 Los geht\'s!' : '🚀 Start!';
} else {
nextBtn.textContent = _currentLang === 'it' ? 'Avanti →' : _currentLang === 'de' ? 'Weiter →' : 'Next →';
}
}
function _setupSelectLang(lang) {
_setupData.lang = lang;
document.querySelectorAll('.setup-lang-btn').forEach(b => b.classList.remove('selected'));
event.target.classList.add('selected');
}
function _setupSkipStep() {
_setupStep++;
_renderSetupStep();
}
function _setupCollectCurrent() {
const realIndex = _setupPendingSteps[_setupStep];
if (realIndex === 1) {
const el = document.getElementById('setup-gemini-key');
if (el) _setupData.gemini_key = el.value.trim();
} else if (realIndex === 2) {
const email = document.getElementById('setup-bring-email');
const pass = document.getElementById('setup-bring-password');
if (email) _setupData.bring_email = email.value.trim();
if (pass) _setupData.bring_password = pass.value.trim();
}
}
function setupWizardNav(dir) {
_setupCollectCurrent();
const totalPending = _setupPendingSteps.length;
const realIndex = _setupPendingSteps[_setupStep];
if (dir === 1 && _setupStep === totalPending - 1) {
_finishSetup();
return;
}
// If language changed, apply it
if (realIndex === 0 && dir === 1 && _setupData.lang !== _currentLang) {
localStorage.setItem('evershelf_lang', _setupData.lang);
localStorage.setItem('evershelf_setup_step', String(_setupStep + 1));
localStorage.setItem('evershelf_setup_pending', JSON.stringify(_setupPendingSteps));
localStorage.setItem('evershelf_setup_data', JSON.stringify(_setupData));
location.reload();
return;
}
_setupStep = Math.max(0, Math.min(totalPending - 1, _setupStep + dir));
_renderSetupStep();
}
async function _finishSetup() {
// Save settings
const s = getSettings();
if (_setupData.gemini_key) s.gemini_key = _setupData.gemini_key;
if (_setupData.bring_email) s.bring_email = _setupData.bring_email;
if (_setupData.bring_password) s.bring_password = _setupData.bring_password;
saveSettingsToStorage(s);
// Save server-side settings (.env) — only send non-empty values to avoid overwriting existing config
const envPayload = {};
if (_setupData.gemini_key) envPayload.gemini_key = _setupData.gemini_key;
if (_setupData.bring_email) envPayload.bring_email = _setupData.bring_email;
if (_setupData.bring_password) envPayload.bring_password = _setupData.bring_password;
try {
if (Object.keys(envPayload).length > 0) {
await api('save_settings', {}, 'POST', envPayload);
}
} catch(e) { /* will work locally */ }
localStorage.setItem('evershelf_setup_done', '1');
localStorage.removeItem('evershelf_setup_step');
localStorage.removeItem('evershelf_setup_data');
document.getElementById('setup-wizard').style.display = 'none';
}
async function _initApp() {
// Check for setup wizard resume (after language change)
const resumeStep = localStorage.getItem('evershelf_setup_step');
const resumeData = localStorage.getItem('evershelf_setup_data');
const resumePending = localStorage.getItem('evershelf_setup_pending');
if (resumeStep && resumePending) {
try { Object.assign(_setupData, JSON.parse(resumeData)); } catch(e) {}
try { _setupPendingSteps = JSON.parse(resumePending); } catch(e) {}
_setupStep = parseInt(resumeStep) || 0;
localStorage.removeItem('evershelf_setup_step');
localStorage.removeItem('evershelf_setup_data');
localStorage.removeItem('evershelf_setup_pending');
document.getElementById('setup-wizard').style.display = '';
_renderSetupStep();
} else {
// Fetch server settings first so .env credentials (Bring!, Gemini)
// are taken into account before deciding which wizard steps to show.
let serverSettings = {};
try { serverSettings = await api('get_settings'); } catch(e) {}
_geminiAvailable = !!(serverSettings.gemini_key_set);
_demoMode = !!serverSettings.demo_mode;
_updateGeminiButtonState();
_applyDemoModeUI();
const missing = _getMissingSetupSteps(serverSettings);
if (missing.length > 0 && !_demoMode) {
showSetupWizard(missing);
}
}
// Migrate old session-based flags to time-based
if (sessionStorage.getItem('_autoAddedCritical')) {
sessionStorage.removeItem('_autoAddedCritical');
}
// One-time reset of bg sync timestamp so first load always triggers a sync
if (!localStorage.getItem('_bgBringSyncReset_v1')) {
localStorage.removeItem('_bgBringSyncTs');
localStorage.setItem('_bgBringSyncReset_v1', '1');
}
syncSettingsFromDB().then(() => {
scaleInit(); // connect to smart scale gateway if configured (needs settings)
initInactivityWatcher();
});
showPage('dashboard');
initSpesaMode();
initScreensaverShortcuts();
startBgShoppingRefresh();
_injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView)
// Hide preloader once the dashboard is rendered
const preloader = document.getElementById('app-preloader');
if (preloader) {
preloader.classList.add('fade-out');
setTimeout(() => preloader.remove(), 380);
}
// Defer update check: fire 6 s after app is ready so it doesn't compete
// with initial API calls and the PHP worker isn't blocked during startup.
setTimeout(_checkWebappUpdate, 6000);
// ── Background intervals ───────────────────────────────────────────────
// 1) Ogni 5 min: ricarica la pagina corrente (scadenze, inventario, ecc.)
setInterval(() => {
if (!_screensaverActive) refreshCurrentPage();
}, 5 * 60 * 1000);
// 2) Ogni 2 min: aggiorna contatore lista spesa nel badge dashboard
setInterval(() => {
if (_screensaverActive) return;
if (_currentPageId === 'shopping') {
loadShoppingList();
} else {
loadShoppingCount();
}
}, 2 * 60 * 1000);
// 3) Aggiorna immediatamente quando la tab torna visibile (es. torni da Bring! app)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
refreshCurrentPage();
_checkWebappUpdate(); // also check for app updates when user returns to tab
}
});
// 4) Background Bring sync ogni 5 min — completamente autonomo, non dipende
// dalla pagina corrente. Aggiunge urgenti, aggiorna spec, rimuove risolti.
_backgroundBringSync();
setInterval(() => { if (!_screensaverActive) _backgroundBringSync(); }, 5 * 60 * 1000);
// 5) Anti-waste live refresh — starts/stops based on connectivity.
window.addEventListener('online', () => { _updateAwLiveDot(true); _startAntiWasteAutoRefresh(); });
window.addEventListener('offline', () => { _updateAwLiveDot(false); clearInterval(_awRefreshTimer); });
// ─────────────────────────────────────────────────────────────────────
}
/**
* Background sync — runs every 5 min regardless of current page.
* Fully autonomous: fetches fresh data, syncs Bring urgency specs,
* adds missing urgent items, removes obsolete auto-added items.
* Never depends on page navigation or user interaction.
*/
async function _backgroundBringSync() {
const lastRun = parseInt(localStorage.getItem('_bgBringSyncTs') || '0');
if (Date.now() - lastRun < 5 * 60 * 1000) return;
localStorage.setItem('_bgBringSyncTs', String(Date.now()));
try {
const [bringData, smartData] = await Promise.all([
api('bring_list').catch(() => null),
api('smart_shopping').catch(() => null),
]);
if (!bringData?.success || !smartData?.success) return;
const listUUID = bringData.listUUID;
const bringItems = bringData.purchase || [];
const smartItems = smartData.items || [];
if (!listUUID || !smartItems.length) return;
// Always update local caches with fresh data
smartShoppingItems = smartItems;
_smartShoppingLastFetch = Date.now();
shoppingListUUID = listUUID;
shoppingItems = bringItems;
const toAdd = []; // new items not yet on Bring
const toUpdate = []; // items on Bring that need spec updated
const toRemove = []; // items on Bring that are no longer urgent (auto-added, now resolved)
// Build set of auto-added item names so we can safely remove them if resolved
const autoAdded = new Set(JSON.parse(localStorage.getItem('_bgAutoAdded') || '[]'));
for (const si of smartItems) {
const expectedSpec = _urgencyToSpec(si.urgency, '');
const bringMatch = bringItems.find(bi => {
if (bi.name.toLowerCase() === si.name.toLowerCase()) return true;
const biFirst = _nameTokens(bi.name)[0];
const siFirst = _nameTokens(si.name)[0];
return biFirst && siFirst && biFirst === siFirst;
});
if (!bringMatch) {
// Not on Bring — add if high/critical and not blocklisted
if ((si.urgency === 'critical' || si.urgency === 'high') && !_isBringPurchased(si.name, si.urgency)) {
toAdd.push({ name: si.name, specification: expectedSpec });
autoAdded.add(si.name.toLowerCase());
}
} else {
// Already on Bring — sync urgency spec unconditionally
const currentSpec = (bringMatch.specification || '').toLowerCase();
const hasUrgencyMarker = currentSpec.includes('urgente') || currentSpec.includes('presto');
const expectedLower = (expectedSpec || '').toLowerCase();
const specChanged = expectedSpec
? !currentSpec.includes(expectedLower.split(' ')[1] || expectedLower) // marker changed
: hasUrgencyMarker; // need to clear
if (specChanged) {
toUpdate.push({ name: bringMatch.name, specification: expectedSpec, update_spec: true });
bringMatch.specification = expectedSpec;
}
}
}
// Remove items auto-added by us that are no longer urgent (resolved)
for (const bi of bringItems) {
const nameLower = bi.name.toLowerCase();
if (!autoAdded.has(nameLower)) continue; // not auto-added by us, skip
const stillUrgent = smartItems.some(si => {
if (si.name.toLowerCase() === nameLower) return si.urgency === 'high' || si.urgency === 'critical';
const siFirst = _nameTokens(si.name)[0];
const biFirst = _nameTokens(bi.name)[0];
return siFirst && biFirst && siFirst === biFirst && (si.urgency === 'high' || si.urgency === 'critical');
});
if (!stillUrgent) {
toRemove.push(bi.name);
autoAdded.delete(nameLower);
}
}
// Persist updated auto-added set
localStorage.setItem('_bgAutoAdded', JSON.stringify([...autoAdded]));
const allChanges = [...toAdd, ...toUpdate];
if (allChanges.length > 0) {
await api('bring_add', {}, 'POST', { items: allChanges, listUUID });
logOperation('bg_bring_sync', { added: toAdd.map(i=>i.name), updated: toUpdate.map(i=>i.name) });
}
if (toRemove.length > 0) {
await api('bring_remove', {}, 'POST', { items: toRemove.map(n => ({ name: n })), listUUID });
logOperation('bg_bring_remove', { removed: toRemove });
}
// Update urgency badge on dashboard without re-rendering anything visible
_updateSmartUrgencyBadge();
// If shopping page is open, re-render it with fresh data
if (_currentPageId === 'shopping') {
_syncOnBringFlags();
_syncTagsFromBringSpec();
renderSmartShopping();
renderShoppingItems();
updateShoppingTabCounts();
}
} catch (e) { /* silent — best effort */ }
}