/** * 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 * @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 = []; const _OFFLINE_LOGS_KEY = '_evershelf_offline_logs'; // buffered log msgs while offline const _OFFLINE_ERRORS_KEY = '_evershelf_offline_errors'; // buffered error reports while offline 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); // If offline, buffer for flush on reconnect instead of losing them const isOfflineNow = (typeof _serverOffline !== 'undefined' && _serverOffline) || (typeof _networkDown !== 'undefined' && _networkDown) || (typeof _offlineMode !== 'undefined' && _offlineMode); if (isOfflineNow) { _bufferOfflineLogs(msgs); return; } fetch(`api/index.php?action=client_log`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: msgs }) }).catch(() => { _bufferOfflineLogs(msgs); }); // store if request itself fails } function _bufferOfflineLogs(msgs) { try { const pending = JSON.parse(localStorage.getItem(_OFFLINE_LOGS_KEY) || '[]'); pending.push(...msgs); if (pending.length > 500) pending.splice(0, pending.length - 500); localStorage.setItem(_OFFLINE_LOGS_KEY, JSON.stringify(pending)); } catch(e) {} } // 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, ts: new Date().toISOString(), }, payload); // When offline, buffer for replay when reconnected (→ GitHub issue on restore) const isOfflineNow = (typeof _serverOffline !== 'undefined' && _serverOffline) || (typeof _networkDown !== 'undefined' && _networkDown) || (typeof _offlineMode !== 'undefined' && _offlineMode); if (isOfflineNow) { _bufferOfflineError(body); return; } fetch('api/index.php?action=report_error', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).catch(() => { _bufferOfflineError(body); }); // store if request itself fails // Note: the server will also skip issue creation if this version is not the latest. } function _bufferOfflineError(body) { try { const pending = JSON.parse(localStorage.getItem(_OFFLINE_ERRORS_KEY) || '[]'); pending.push(body); if (pending.length > 50) pending.splice(0, pending.length - 50); localStorage.setItem(_OFFLINE_ERRORS_KEY, JSON.stringify(pending)); } catch(e) {} } // ── 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/, ''); // ── Broken image fallback ───────────────────────────────────────────────────── // External product images (Open Food Facts, etc.) are unavailable when offline. // Replace any broken with a neutral grey placeholder so the layout stays intact. document.addEventListener('error', (e) => { if (e.target.tagName !== 'IMG' || e.target.dataset.offlineErr) return; e.target.dataset.offlineErr = '1'; // 60x60 grey placeholder SVG with a '?' glyph e.target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Crect width='60' height='60' rx='8' fill='%231e293b'/%3E%3Ctext x='30' y='38' text-anchor='middle' fill='%2364748b' font-size='24' font-family='sans-serif'%3E%3F%3C/text%3E%3C/svg%3E"; e.target.style.opacity = '0.45'; }, true); // ── 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', t('gemini.chat_title')); } else { btn.classList.add('header-btn-no-ai'); btn.setAttribute('title', t('gemini.not_configured')); } } 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 _semverGt(a, b) { // Returns true if version string a is strictly greater than b (e.g. "1.7.25" > "1.7.23") const pa = a.split('.').map(Number); const pb = b.split('.').map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const na = pa[i] || 0, nb = pb[i] || 0; if (na !== nb) return na > nb; } return false; } 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 && _semverGt(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 && _semverGt(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}` : t('update.new_version')) : (latestTag ? `v${latestTag}` : t('update.new_version')); const hideBadge = () => { badge.style.display = 'none'; badge.innerHTML = ''; if (!deployChanged) localStorage.setItem(SEEN_KEY, publishedAt); }; badge.innerHTML = `⬆️ ${versionLabel}` + `` + ``; 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 _scaleAuthQuery() { const tok = typeof getApiToken === 'function' ? getApiToken() : ''; return tok ? '&api_token=' + encodeURIComponent(tok) : ''; } function _scaleConnect(url) { if (_scaleEs) { try { _scaleEs.close(); } catch(e) {} _scaleEs = null; } if (_scaleReconnectTimer) { clearTimeout(_scaleReconnectTimer); _scaleReconnectTimer = null; } try { // EventSource cannot send custom headers — pass api_token in query string _scaleEs = new EventSource('api/scale_relay.php?url=' + encodeURIComponent(url) + _scaleAuthQuery()); _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'); // Update protocol info in settings diagnostic if (msg.protocol) { const protoEl = document.getElementById('scale-diag-proto'); if (protoEl) protoEl.textContent = `📡 ${msg.protocol}`; } // 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-2g jitter for stability decisions: changes below 2g are considered noise. const SCALE_NOISE_G = 2; let effectiveStable = !!msg.stable; const grams = _scaleToGrams(rawValue, msg.unit); if (grams !== null) { if (effectiveStable) { _scaleLastStableGrams = grams; } else if (_scaleLastStableGrams !== null) { if (Math.abs(grams - _scaleLastStableGrams) < SCALE_NOISE_G) { 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 ? ' ✓' : ' …'}`; // Update settings diagnostic live weight const diagW = document.getElementById('scale-diag-weight'); if (diagW) diagW.textContent = `${parseFloat(msg.value).toFixed(1)} ${msg.unit || 'g'}`; // 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 < 2 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 < 2 && 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) { const targetLbl = getUnitDisplayLabel(getActiveUseUnitLabel()); lblEl.textContent = targetLbl ? ((t('qty.enter_in') || 'Inserimento in') + ' ' + targetLbl) : ''; } } } /** * 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) { // Scale reads weight (g/ml) — only auto-fill in sub-unit mode. // If the user chose "Confezioni", respect that (fraction buttons / manual qty). if (_useConfMode._activeUnit === 'conf') return; 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) { _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. // Threshold: 5g — gives enough time to tare after opening the modal. if (_scaleLastConfirmedGrams !== null && Math.abs(grams - _scaleLastConfirmedGrams) < 5) { 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] || ''; // Update settings live-diagnostic panel const diag = document.getElementById('scale-live-diag'); if (!diag) return; diag.style.display = state === 'connected' ? '' : 'none'; if (state === 'connected') { const devEl = document.getElementById('scale-diag-device'); const batEl = document.getElementById('scale-diag-battery'); if (devEl) devEl.textContent = _scaleDevice || t('settings.scale.unknown_device'); if (batEl) batEl.textContent = _scaleBattery != null ? `🔋 ${_scaleBattery}%` : ''; const weightEl = document.getElementById('scale-diag-weight'); if (weightEl && _scaleLatestWeight) { weightEl.textContent = `${parseFloat(_scaleLatestWeight.value).toFixed(1)} ${_scaleLatestWeight.unit || 'g'}`; } } } /** * 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 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 = `

${t('scale.place_on_scale')}

— — —

${escapeHtml((t('qty.enter_in') || 'Inserimento in') + ' ' + getUnitDisplayLabel(unit))}

${t('scale.waiting_stable')}

`; 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) { const canUseByWeight = _useNormalUnit === 'g' || _useNormalUnit === 'ml' || (_useConfMode && (_useConfMode.packageUnit === 'g' || _useConfMode.packageUnit === 'ml')); btnUse.style.display = (ready && canUseByWeight) ? '' : '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) + _scaleAuthQuery(), { 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 = t('settings.scale.discover_scanning'); try { const res = await fetch('api/scale_discover.php', { signal: AbortSignal.timeout(8000), headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) } }); 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 = t('settings.scale.discover_found', { url, more: 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 = t('settings.scale.discover_not_found', { subnet: data.subnet || 'local network' }); } } catch(e) { status.textContent = t('settings.scale.discover_failed', { error: e.message || 'timeout' }); } btn.disabled = false; btn.textContent = t('settings.scale.discover_auto'); } // ===== i18n TRANSLATION SYSTEM ===== const _I18N_VERSION = '20260606n'; // bump when translations change let _i18nStrings = null; // current language translations (flat) let _i18nFallback = null; // Italian fallback (flat) let _i18nLoadedVersion = null; let _currentLang = localStorage.getItem('evershelf_lang') || navigator.language?.slice(0, 2) || 'en'; const _SUPPORTED_LANGS = { it: 'Italiano', en: 'English', de: 'Deutsch', fr: 'Français', es: 'Español' }; if (!_SUPPORTED_LANGS[_currentLang]) _currentLang = 'en'; // Apply theme IMMEDIATELY to prevent flash of unstyled content (function _earlyTheme() { try { // Use dedicated key (server-synced); fall back to old full-settings object for back-compat let mode = localStorage.getItem('evershelf_dark_mode'); if (!mode) { const s = JSON.parse(localStorage.getItem('evershelf_settings') || '{}'); mode = s.dark_mode || 'auto'; } const h = new Date().getHours(); const dark = mode === 'on' || (mode === 'auto' && (h >= 20 || h < 7)); document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); } catch(e) {} })(); // 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 { const bust = _I18N_VERSION; // Reload fallback when version changes (avoids stale cached keys) if (!_i18nFallback || _i18nLoadedVersion !== bust) { const fbRes = await fetch(`translations/it.json?v=${bust}`); if (fbRes.ok) _i18nFallback = _flattenI18n(await fbRes.json()); _i18nLoadedVersion = bust; } if (lang === 'it') { _i18nStrings = _i18nFallback; } else { const res = await fetch(`translations/${encodeURIComponent(lang)}.json?v=${bust}`); 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 key of Object.keys(CATEGORY_LABELS)) { const tKey = `categories.${key}`; const translated = _i18nStrings[tKey]; if (translated) { const icon = CATEGORY_ICONS[key] || '📦'; CATEGORY_LABELS[key] = `${icon} ${translated}`; } } const pfCat = document.getElementById('pf-category'); if (pfCat) { const curVal = pfCat.value; pfCat.innerHTML = `` + Object.entries(CATEGORY_LABELS).map(([k, label]) => ``).join(''); if (curVal) pfCat.value = curVal; } 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(); } // ===== DARK MODE ===== function _applyTheme() { const s = getSettings(); const mode = s.dark_mode || 'auto'; let isDark; if (mode === 'on') { isDark = true; } else if (mode === 'off') { isDark = false; } else { // auto: dark from 20:00 to 07:00 (time-based, not system preference) const h = new Date().getHours(); isDark = h >= 20 || h < 7; } document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); } function _setThemeMode(mode) { const s = getSettings(); s.dark_mode = mode; saveSettingsToStorage(s); _applyTheme(); // Persist dark_mode to server .env immediately (no need to send the full // settings payload — save_settings only updates keys present in the body // and keeps all other .env values intact). const token = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : ''); const headers = token ? { 'X-API-Token': token } : {}; api('save_settings', {}, 'POST', { dark_mode: mode }, headers).catch(() => {}); } // Listen to system theme changes (for 'auto' mode) // Re-evaluate auto theme every 5 minutes (catches 20:00 dark / 07:00 light transitions) setInterval(() => { if ((getSettings().dark_mode || 'auto') === 'auto') _applyTheme(); }, 5 * 60 * 1000); // ===== EXPORT INVENTORY ===== function exportInventory(format) { const tok = typeof getApiToken === 'function' ? getApiToken() : ''; const tokParam = tok ? `&api_token=${encodeURIComponent(tok)}` : ''; const url = `api/index.php?action=export_inventory&format=${encodeURIComponent(format)}&_t=${Date.now()}${tokParam}`; if (format === 'csv') { // Direct download via trick const a = document.createElement('a'); a.href = url; a.download = `evershelf-inventory-${new Date().toISOString().slice(0,10)}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); } else { // Open print-ready HTML in new tab window.open(url, '_blank', 'noopener'); } } function _showExportModal() { const html = `

${t('export.hint')}

`; document.getElementById('modal-content').innerHTML = html; document.getElementById('modal-overlay').style.display = 'flex'; } let 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') }, }; async function loadCustomLocations() { try { const result = await api('locations_list', {}, 'GET'); if (result.success && Array.isArray(result.locations)) { result.locations.forEach(loc => { if (loc.is_builtin) return; // built-ins already have translated labels above LOCATIONS[loc.key] = { icon: loc.icon || '📦', label: loc.label }; }); } } catch (e) { console.warn('[EverShelf] Could not load custom locations:', e); } } /** * Render location selector buttons dynamically into a container. * @param {string} containerId - DOM id of the container holding the buttons * @param {string} activeKey - currently selected location key * @param {string} onClickFnName - name of the click handler function (e.g. 'selectLocation') */ function renderLocationButtons(containerId, activeKey, onClickFnName) { const container = document.getElementById(containerId); if (!container) return; const keys = Object.keys(LOCATIONS); container.innerHTML = keys.map(key => { const info = LOCATIONS[key]; const isActive = key === activeKey ? 'active' : ''; return ``; }).join(''); } function showConfigTab(tab) { document.querySelectorAll('.config-tab-content').forEach(el => el.style.display = 'none'); const target = document.getElementById('config-tab-' + tab); if (target) target.style.display = 'block'; document.querySelectorAll('.config-tab-btn').forEach(btn => { const isActive = btn.dataset.tab === tab; btn.classList.toggle('btn-primary', isActive); btn.classList.toggle('btn-secondary', !isActive); }); } async function _loadConfigPage() { const container = document.getElementById('locations-list-container'); if (!container) return; container.innerHTML = `

Chargement…

`; try { const result = await api('locations_list', {}, 'GET'); if (!result.success) { container.innerHTML = `

Erreur de chargement.

`; return; } renderLocationsList(result.locations); } catch (e) { container.innerHTML = `

Erreur de chargement.

`; } await _loadCategoriesConfigSection(); await _loadSubcategoryConfigSection(); await _loadRecipeTagsConfigSection(); await _loadCustomUnitsConfigSection(); } async function _loadRecipeTagsConfigSection() { const container = document.getElementById('recipe-tags-list-container'); if (!container) return; container.innerHTML = `

Chargement…

`; try { const result = await api('recipe_tags_list', {}, 'GET'); if (!result.success) { container.innerHTML = `

Erreur de chargement.

`; return; } RECIPE_TAGS = result.tags; renderRecipeTagsConfigList(result.tags); } catch (e) { container.innerHTML = `

Erreur de chargement.

`; } } function renderRecipeTagsConfigList(tags) { const container = document.getElementById('recipe-tags-list-container'); if (!container) return; if (!tags || tags.length === 0) { container.innerHTML = `

Aucun tag.

`; return; } container.innerHTML = tags.map(tag => `
`).join(''); } async function addRecipeTagConfig() { const iconInput = document.getElementById('new-recipe-tag-icon'); const labelInput = document.getElementById('new-recipe-tag-label'); const keywordsInput = document.getElementById('new-recipe-tag-keywords'); const icon = iconInput.value.trim() || '🏷️'; const label = labelInput.value.trim(); const keywords = keywordsInput ? keywordsInput.value.trim() : ''; if (!label) { showToast('Indique un nom pour le nouveau tag', 'warning'); return; } showLoading(true); try { const result = await api('recipe_tags_add', {}, 'POST', { label, icon, keywords }); showLoading(false); if (result.success) { showToast(`Tag "${label}" ajouté`, 'success'); iconInput.value = ''; labelInput.value = ''; if (keywordsInput) keywordsInput.value = ''; _loadRecipeTagsConfigSection(); } else { showToast(result.error || 'Erreur lors de l\'ajout', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de l\'ajout', 'error'); } } async function updateRecipeTagConfig(key) { const icon = document.getElementById(`rtag-icon-${key}`).value.trim() || '🏷️'; const label = document.getElementById(`rtag-label-${key}`).value.trim(); const keywordsEl = document.getElementById(`rtag-keywords-${key}`); const keywords = keywordsEl ? keywordsEl.value.trim() : ''; if (!label) { showToast('Le nom ne peut pas être vide', 'warning'); return; } showLoading(true); try { const result = await api('recipe_tags_update', {}, 'POST', { key, label, icon, keywords }); showLoading(false); if (result.success) { showToast('Tag mis à jour', 'success'); _loadRecipeTagsConfigSection(); } else { showToast(result.error || 'Erreur', 'error'); } } catch (e) { showLoading(false); showToast('Erreur', 'error'); } } async function removeRecipeTagConfig(key) { if (!confirm(`Supprimer ce tag ?`)) return; showLoading(true); try { const result = await api('recipe_tags_remove', {}, 'POST', { key }); showLoading(false); if (result.success) { showToast('Tag supprimé', 'success'); _loadRecipeTagsConfigSection(); } else { showToast(result.error || 'Impossible de supprimer', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de la suppression', 'error'); } } async function _loadCustomUnitsConfigSection() { const container = document.getElementById('custom-units-list-container'); if (!container) return; container.innerHTML = `

Chargement…

`; try { const result = await api('custom_units_list', {}, 'GET'); if (!result.success) { container.innerHTML = `

Erreur de chargement.

`; return; } CUSTOM_UNITS = result.units; renderCustomUnitsConfigList(result.units); } catch (e) { container.innerHTML = `

Erreur de chargement.

`; } } function renderCustomUnitsConfigList(units) { const container = document.getElementById('custom-units-list-container'); if (!container) return; if (!units || units.length === 0) { container.innerHTML = `

Aucune unité personnalisée.

`; return; } const baseOptionsHtml = (selected) => ['g', 'ml', 'pz'].map(b => ``).join(''); container.innerHTML = units.map(u => `
${escapeHtml(u.key)}
`).join(''); } async function addCustomUnitConfig() { const iconInput = document.getElementById('new-unit-icon'); const keyInput = document.getElementById('new-unit-key'); const labelInput = document.getElementById('new-unit-label'); const baseInput = document.getElementById('new-unit-base'); const factorInput = document.getElementById('new-unit-factor'); const icon = iconInput.value.trim() || '📏'; const key = keyInput.value.trim(); const label = labelInput.value.trim(); const base_unit = baseInput.value; const factor = parseFloat(factorInput.value); if (!key || !label) { showToast('Indique une clé et un nom pour la nouvelle unité', 'warning'); return; } if (!factor || factor <= 0) { showToast('Indique un facteur de conversion valide', 'warning'); return; } showLoading(true); try { const result = await api('custom_units_add', {}, 'POST', { key, label, icon, base_unit, factor }); showLoading(false); if (result.success) { showToast(`Unité "${label}" ajoutée`, 'success'); keyInput.value = ''; labelInput.value = ''; factorInput.value = ''; _loadCustomUnitsConfigSection(); } else { showToast(result.error || 'Erreur lors de l\'ajout', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de l\'ajout', 'error'); } } async function updateCustomUnitConfig(key) { const icon = document.getElementById(`cunit-icon-${key}`).value.trim() || '📏'; const label = document.getElementById(`cunit-label-${key}`).value.trim(); const base_unit = document.getElementById(`cunit-base-${key}`).value; const factor = parseFloat(document.getElementById(`cunit-factor-${key}`).value); if (!label) { showToast('Le nom ne peut pas être vide', 'warning'); return; } if (!factor || factor <= 0) { showToast('Facteur de conversion invalide', 'warning'); return; } showLoading(true); try { const result = await api('custom_units_update', {}, 'POST', { key, label, icon, base_unit, factor }); showLoading(false); if (result.success) { showToast('Unité mise à jour', 'success'); _loadCustomUnitsConfigSection(); } else { showToast(result.error || 'Erreur', 'error'); } } catch (e) { showLoading(false); showToast('Erreur', 'error'); } } async function removeCustomUnitConfig(key) { if (!confirm(`Supprimer cette unité ?`)) return; showLoading(true); try { const result = await api('custom_units_remove', {}, 'POST', { key }); showLoading(false); if (result.success) { showToast('Unité supprimée', 'success'); _loadCustomUnitsConfigSection(); } else { showToast(result.error || 'Impossible de supprimer', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de la suppression', 'error'); } } async function _loadCategoriesConfigSection() { const container = document.getElementById('categories-list-container'); if (!container) return; container.innerHTML = `

Chargement…

`; try { const result = await api('categories_list', {}, 'GET'); if (!result.success) { container.innerHTML = `

Erreur de chargement.

`; return; } renderCategoriesList(result.categories); } catch (e) { container.innerHTML = `

Erreur de chargement.

`; } } function renderCategoriesList(categories) { const container = document.getElementById('categories-list-container'); if (!container) return; if (!categories || categories.length === 0) { container.innerHTML = `

Aucune catégorie.

`; return; } container.innerHTML = categories.map(cat => { const builtinBadge = cat.is_builtin ? `native` : ''; const deleteBtn = cat.is_builtin ? '' : ``; return `
${builtinBadge} ${deleteBtn}
`; }).join(''); } async function addCategory() { const iconInput = document.getElementById('new-category-icon'); const labelInput = document.getElementById('new-category-label'); const keywordsInput = document.getElementById('new-category-keywords'); const icon = iconInput.value.trim() || '📦'; const label = labelInput.value.trim(); const keywords = keywordsInput.value.trim(); if (!label) { showToast('Indique un nom pour la nouvelle catégorie', 'warning'); return; } showLoading(true); try { const result = await api('categories_add', {}, 'POST', { label, icon, keywords }); showLoading(false); if (result.success) { showToast(`Catégorie "${label}" ajoutée`, 'success'); iconInput.value = ''; labelInput.value = ''; keywordsInput.value = ''; CATEGORY_LABELS[result.key] = label; CATEGORY_ICONS[result.key] = icon; if (!CATEGORY_LOCATION[result.key]) CATEGORY_LOCATION[result.key] = 'dispensa'; if (keywords) CUSTOM_CATEGORY_KEYWORDS[result.key] = keywords.split(',').map(k => k.trim().toLowerCase()).filter(Boolean); _loadCategoriesConfigSection(); } else { showToast(result.error || 'Erreur lors de l\'ajout', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de l\'ajout', 'error'); } } async function updateCategory(key) { const icon = document.getElementById(`cat-icon-${key}`).value.trim() || '📦'; const label = document.getElementById(`cat-label-${key}`).value.trim(); const keywords = document.getElementById(`cat-keywords-${key}`).value.trim(); if (!label) { showToast('Le nom ne peut pas être vide', 'warning'); return; } showLoading(true); try { const result = await api('categories_update', {}, 'POST', { key, label, icon, keywords }); showLoading(false); if (result.success) { showToast('Catégorie mise à jour', 'success'); CATEGORY_LABELS[key] = label; CATEGORY_ICONS[key] = icon; CUSTOM_CATEGORY_KEYWORDS[key] = keywords ? keywords.split(',').map(k => k.trim().toLowerCase()).filter(Boolean) : []; } else { showToast(result.error || 'Erreur', 'error'); } } catch (e) { showLoading(false); showToast('Erreur', 'error'); } } async function removeCategory(key) { if (!confirm(`Supprimer la catégorie "${CATEGORY_LABELS[key] || key}" ?`)) return; showLoading(true); try { const result = await api('categories_remove', {}, 'POST', { key }); showLoading(false); if (result.success) { showToast('Catégorie supprimée', 'success'); delete CATEGORY_LABELS[key]; delete CATEGORY_ICONS[key]; delete CUSTOM_CATEGORY_KEYWORDS[key]; _loadCategoriesConfigSection(); } else { showToast(result.error || 'Impossible de supprimer', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de la suppression', 'error'); } } async function _loadSubcategoryConfigSection() { const catSelect = document.getElementById('subcat-config-category'); if (!catSelect) return; if (!catSelect.dataset.populated) { catSelect.innerHTML = Object.entries(CATEGORY_LABELS).map(([key, label]) => ``).join(''); catSelect.dataset.populated = 'true'; } await renderSubcategoryConfigForCategory(catSelect.value || Object.keys(CATEGORY_LABELS)[0]); } async function renderSubcategoryConfigForCategory(category) { const catSelect = document.getElementById('subcat-config-category'); if (catSelect) catSelect.value = category; const requiredCheckbox = document.getElementById('subcat-config-required'); if (requiredCheckbox) requiredCheckbox.checked = REQUIRED_SUBCATEGORY_CATEGORIES.includes(category); const container = document.getElementById('subcat-list-container'); if (!container) return; const list = SUBCATEGORIES_BY_CATEGORY[category] || []; if (list.length === 0) { container.innerHTML = `

Aucune sous-catégorie pour cette catégorie.

`; return; } container.innerHTML = list.map(sc => `
`).join(''); } async function onSubcatConfigCategoryChange() { const cat = document.getElementById('subcat-config-category')?.value || ''; await renderSubcategoryConfigForCategory(cat); } async function toggleSubcategoryRequired() { const category = document.getElementById('subcat-config-category')?.value || ''; const checked = document.getElementById('subcat-config-required')?.checked; let updated = REQUIRED_SUBCATEGORY_CATEGORIES.slice(); if (checked && !updated.includes(category)) updated.push(category); if (!checked) updated = updated.filter(c => c !== category); showLoading(true); try { const result = await api('app_settings_save', {}, 'POST', { settings: { subcategory_required_categories: updated } }); showLoading(false); if (result.success) { REQUIRED_SUBCATEGORY_CATEGORIES = updated; showToast('Préférence enregistrée', 'success'); } else { showToast('Erreur lors de l\'enregistrement', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de l\'enregistrement', 'error'); } } async function addSubcategoryRow() { const category = document.getElementById('subcat-config-category')?.value || ''; const labelInput = document.getElementById('new-subcat-label'); const label = labelInput.value.trim(); if (!category) { showToast('Choisis une catégorie', 'warning'); return; } if (!label) { showToast('Indique un nom pour la nouvelle sous-catégorie', 'warning'); return; } showLoading(true); try { const result = await api('subcategories_add', {}, 'POST', { category, label }); showLoading(false); if (result.success) { showToast(`Sous-catégorie "${label}" ajoutée`, 'success'); labelInput.value = ''; if (!SUBCATEGORIES_BY_CATEGORY[category]) SUBCATEGORIES_BY_CATEGORY[category] = []; SUBCATEGORIES_BY_CATEGORY[category].push({ id: result.id, category, key: result.key, label }); SUBCATEGORY_LABEL_LOOKUP[category + '::' + result.key] = label; renderSubcategoryConfigForCategory(category); } else { showToast(result.error || 'Erreur lors de l\'ajout', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de l\'ajout', 'error'); } } async function updateSubcategoryRow(id) { const label = document.getElementById(`subcat-label-${id}`)?.value.trim(); if (!label) { showToast('Le nom ne peut pas être vide', 'warning'); return; } showLoading(true); try { const result = await api('subcategories_update', {}, 'POST', { id, label }); showLoading(false); if (result.success) { showToast('Sous-catégorie mise à jour', 'success'); for (const cat in SUBCATEGORIES_BY_CATEGORY) { const row = SUBCATEGORIES_BY_CATEGORY[cat].find(sc => sc.id === id); if (row) { row.label = label; SUBCATEGORY_LABEL_LOOKUP[cat + '::' + row.key] = label; break; } } } else { showToast(result.error || 'Erreur', 'error'); } } catch (e) { showLoading(false); showToast('Erreur', 'error'); } } async function removeSubcategoryRow(id) { if (!confirm('Supprimer cette sous-catégorie ?')) return; showLoading(true); try { const result = await api('subcategories_remove', {}, 'POST', { id }); showLoading(false); if (result.success) { showToast('Sous-catégorie supprimée', 'success'); for (const cat in SUBCATEGORIES_BY_CATEGORY) { SUBCATEGORIES_BY_CATEGORY[cat] = SUBCATEGORIES_BY_CATEGORY[cat].filter(sc => sc.id !== id); } const category = document.getElementById('subcat-config-category')?.value || ''; renderSubcategoryConfigForCategory(category); } else { showToast(result.error || 'Impossible de supprimer', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de la suppression', 'error'); } } function renderLocationsList(locations) { const container = document.getElementById('locations-list-container'); if (!container) return; if (!locations || locations.length === 0) { container.innerHTML = `

Aucun emplacement.

`; return; } container.innerHTML = locations.map(loc => { const builtinBadge = loc.is_builtin ? `natif` : ''; const deleteBtn = loc.is_builtin ? '' : ``; return `
${builtinBadge} ${deleteBtn}
`; }).join(''); } async function addLocation() { const iconInput = document.getElementById('new-location-icon'); const labelInput = document.getElementById('new-location-label'); const icon = iconInput.value.trim() || '📦'; const label = labelInput.value.trim(); if (!label) { showToast('Indique un nom pour le nouvel emplacement', 'warning'); return; } showLoading(true); try { const result = await api('locations_add', {}, 'POST', { label, icon }); showLoading(false); if (result.success) { showToast(`Emplacement "${label}" ajouté`, 'success'); iconInput.value = ''; labelInput.value = ''; LOCATIONS[result.key] = { icon, label }; _loadConfigPage(); } else { showToast(result.error || 'Erreur lors de l\'ajout', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de l\'ajout', 'error'); } } async function updateLocation(key) { const icon = document.getElementById(`loc-icon-${key}`).value.trim() || '📦'; const label = document.getElementById(`loc-label-${key}`).value.trim(); if (!label) { showToast('Le nom ne peut pas être vide', 'warning'); return; } showLoading(true); try { const result = await api('locations_update', {}, 'POST', { key, label, icon }); showLoading(false); if (result.success) { showToast('Emplacement mis à jour', 'success'); LOCATIONS[key] = { icon, label }; } else { showToast(result.error || 'Erreur', 'error'); } } catch (e) { showLoading(false); showToast('Erreur', 'error'); } } async function removeLocation(key) { if (!confirm(`Supprimer l'emplacement "${LOCATIONS[key]?.label || key}" ?`)) return; showLoading(true); try { const result = await api('locations_remove', {}, 'POST', { key }); showLoading(false); if (result.success) { showToast('Emplacement supprimé', 'success'); delete LOCATIONS[key]; _loadConfigPage(); } else { showToast(result.error || 'Impossible de supprimer', 'error'); } } catch (e) { showLoading(false); showToast('Erreur lors de la suppression', 'error'); } } const CATEGORY_ICONS = { 'latticini': '🥛', 'carne': '🥩', 'pesce': '🐟', 'frutta': '🍎', 'verdura': '🥬', 'pasta': '🍝', 'pane': '🍞', 'surgelati': '🧊', 'bevande': '🥤', 'condimenti': '🧂', 'snack': '🍪', 'conserve': '🥫', 'cereali': '🌾', 'igiene': '🧴', 'pulizia': '🧹', 'altro': '📦' }; // Mots-clés des catégories personnalisées (utilisateur), pour la détection auto par nom de produit let CUSTOM_CATEGORY_KEYWORDS = {}; async function loadCustomCategories() { try { const result = await api('categories_list', {}, 'GET'); if (result.success && Array.isArray(result.categories)) { CUSTOM_CATEGORY_KEYWORDS = {}; result.categories.forEach(cat => { CATEGORY_LABELS[cat.key] = cat.label; CATEGORY_ICONS[cat.key] = cat.icon || '📦'; if (!CATEGORY_LOCATION[cat.key]) CATEGORY_LOCATION[cat.key] = 'dispensa'; if (cat.keywords) { CUSTOM_CATEGORY_KEYWORDS[cat.key] = cat.keywords.split(',').map(k => k.trim().toLowerCase()).filter(Boolean); } }); } } catch (e) { console.warn('[EverShelf] Could not load custom categories:', e); } } // 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, productBrand = '') { if (!ofCategory) { return guessCategoryFromName(productName || '', productBrand || ''); } const cat = ofCategory.toLowerCase(); // Direct match with our local keys — but NOT 'altro': fall through to name guess for (const key of Object.keys(CATEGORY_ICONS)) { if (cat === key && key !== 'altro') 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 || '', productBrand || ''); } // "beverages-and-beverages-preparations" = actual beverages if (/^en:beverages/.test(cat)) return 'bevande'; // sweeteners = condimenti if (/sweetener|dolcific/.test(cat)) return 'condimenti'; // food-additives, cooking-helpers, flavourings = condimenti if (/food-additive|cooking-helper|aromi|flavour/.test(cat)) return 'condimenti'; // breakfasts = cereali if (/breakfast/.test(cat)) return 'cereali'; // dried-products = conserve if (/dried-product/.test(cat)) return 'conserve'; // Specific tag patterns if (/dairi|dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte\b|egg\b|uova\b|uovo\b|poultry-egg/.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 (/salad|insalat/.test(cat)) { return _isPreparedSaladName(productName, productBrand) ? 'pasta' : 'verdura'; } if (/vegetable|verdur|legum|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 (/preserve|jam|marmellat|miele|honey|canned|pelati|passata/.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'; // Last resort: try product name before giving up const nameGuess = guessCategoryFromName(productName || '', productBrand || ''); if (nameGuess !== 'altro') return nameGuess; return 'altro'; } /** Prepared rice/pasta salads — not fresh leafy salad (verdura). */ function _isPreparedSaladName(name, brand = '') { const n = (name || '').toLowerCase(); const b = (brand || '').toLowerCase(); if (/insalata\s+di\s+(riso|pasta|farro|orzo|couscous|quinoa|bulgur|cereali|legumi)\b/.test(n)) return true; if (/\b(riso|pasta)\s+con\b/.test(n) && /\binsalata\b/.test(n)) return true; if (/\binsalata\b/.test(n) && /\b(ponti|rio mare|orogel|findus|star)\b/.test(b)) return true; return false; } // Guess a local category purely from product name function guessCategoryFromName(name, brand = '') { if (!name) return 'altro'; const n = name.toLowerCase(); if (_isPreparedSaladName(n, brand)) return 'pasta'; // ── Known Italian brand names → direct category (fast-path before regex) // "Uno" only if it starts the name (Bahlsen biscuits, not the Italian word) if (/^uno\b/.test(n)) return 'snack'; const _brandRx = [ [/\b(baiocchi|macine|tarallucci|tegolini|pavesini|plasmon|loacker|manner|digestive|oreo|hanuta|ringo|abbracci|gocciole|pan di stelle|oro saiwa|kinder|ferrero rocher|raffaello|bounty|twix|snickers|pringles|fonzies|tuc\b|ritz\b|mulino bianco|gran cereale|gocciole|saiwa|togo|principe|oro ciok|kit ?kat)\b/, 'snack'], [/\b(barilla|de cecco|garofalo|la molisana|rummo|voiello|divella|agnesi|buitoni)\b/, 'pasta'], [/\b(galbani|granarolo|yomo|danone|muller|müller|pr[eé]sident|santa lucia|jocca|fiorfiore)\b/, 'latticini'], [/\b(mutti|cirio)\b/, 'conserve'], [/\b(san pellegrino|levissima|ferrarelle|lete|nestea|lipton|nescaf[eé]|lavazza|illy\b|kimbo|segafredo)\b/, 'bevande'], ]; for (const [rx, cat] of _brandRx) { if (rx.test(n)) return cat; } // Pasta & Rice if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette|sedani|tortiglioni|calamarata|spaghettini|vermicelli/.test(n)) return 'pasta'; // Pane & Forno if (/pane\b|bauletto|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini\b|sandwich|taralli|pancarr[eè]|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b|pangrattato|pane grattugiato|pan.*carr[eè]/.test(n)) return 'pane'; // Latticini (before bevande to avoid latte→bevande) if (/latte\b|yogurt|y[o]?gurt|yaourt|yougurt|yoghurt|formaggio|mozzarella|burro\b|panna\b|ricott|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie\b|camembert|emmental|asiago|feta\b|provola|caciotta|caprino|philadelphia|skyr|kefir|labneh/.test(n)) return 'latticini'; // Conserve — controllo tonno\b PRIMA di condimenti (che ha olio\b) if (/passata|pelati|pomodoro\b|pomodori|pomodorini|ciliegino|sugo\b|polpa di pomod|marmellata|miele\b|zagara|legumi|ceci\b|fagioli\b|lenticchie|olive\b|tonno\b|sgombro in scatola|concentrato|brodo\b|dado\b|besciamella|datterini|passato di/.test(n)) return 'conserve'; // Condimenti (include spezie, farine, zucchero, aromi, lieviti) if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina\b|maionese|ketchup|senape|salsa\b|paprika|curry\b|cannella|noce moscata|origano|rosmarino|timo\b|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido\b|maizena|semola|pesto\b|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco|aroma\b|aromi\b|arome\b|estratto.*vaniglia|estratto.*limone|polenta\b|semolino\b|cacao amaro|cacao.*polvere|purea|pure\b|pur[ée]e/.test(n)) return 'condimenti'; // Bevande (after latticini to avoid latte conflict) if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caff[eè]\b|kaffee|kafè|t[eè]\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino|ciobar|ovomaltine|zuppalatte|cioccolata.*calda|latte.*cioccolato/.test(n)) return 'bevande'; // Carne (include salumi) if (/pollo\b|manzo|maiale|vitello|tacchino|prosciutto|salame\b|bresaola|mortadella|wurstel|speck\b|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola|schinken|scamorza affumicat|spianata/.test(n)) return 'carne'; // Pesce if (/tonno\b|salmone|merluzzo|pesce\b|sgombro\b|gamberi|acciughe|baccal[aà]|vongole|cozze|calamari|surimi|alici|branzino|orata\b|sardine|trota|dentice|seppia|polpo|filetto.*pesce|pesce.*filetto/.test(n)) return 'pesce'; // Frutta if (/mela\b|mele\b|banana|arancia|pera\b|fragola|uva\b|kiwi\b|limone|frutta\b|mandarino|clementina|pompelmo|avocado|mango\b|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta'; // Verdura if (/insalata|zucchina|zucchine|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais\b|zucca\b|aglio\b|cetriolo|rapa\b|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura'; // Surgelati if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato|potato wedge|potato.*wedge/.test(n)) return 'surgelati'; // Snack & Dolci if (/biscott|cioccolat|nutella|merendine\b|merendina|patatine|caramelle|wafer|cialda|cialdine|sfornatini|torta\b|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramis[uù]|cantucci|amaretti|savoiardi|pralin|confetti dolci|chicchi.*cacao|cacao.*chicchi|risofrolle|sfogliatine|ossi di morto|canestrelli|snack/.test(n)) return 'snack'; // Cereali if (/cereali|muesli|fiocchi|granola|porridge|avena|mix energia|misto cereal|farro\b|orzo\b|quinoa/.test(n)) return 'cereali'; // Igiene personale if (/sapone|shampoo|dentifricio|deodorante|carta igienica|fazzoletti|cotton fioc|assorbente|rasoio|schiuma da barba|gel doccia|balsamo\b|lozione/.test(n)) return 'igiene'; // Pulizia casa if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti\b|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia'; // Mots-clés personnalisés définis via Config (catégories ajoutées par l'utilisateur) for (const [key, keywords] of Object.entries(CUSTOM_CATEGORY_KEYWORDS)) { if (keywords.some(kw => kw && n.includes(kw))) return key; } 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')}` }; // Sous-catégories chargées dynamiquement depuis la base (table subcategories), gérables via Config let SUBCATEGORIES_BY_CATEGORY = {}; // { category: [{id, category, key, label}, ...] } let SUBCATEGORY_LABEL_LOOKUP = {}; // { "category::key": label } — évite les collisions entre catégories let REQUIRED_SUBCATEGORY_CATEGORIES = ['bevande']; // valeur par défaut, écrasée par syncSettingsFromDB() async function loadCustomSubcategories() { try { const result = await api('subcategories_list', {}, 'GET'); if (result.success && Array.isArray(result.subcategories)) { SUBCATEGORIES_BY_CATEGORY = {}; SUBCATEGORY_LABEL_LOOKUP = {}; result.subcategories.forEach(sc => { if (!SUBCATEGORIES_BY_CATEGORY[sc.category]) SUBCATEGORIES_BY_CATEGORY[sc.category] = []; SUBCATEGORIES_BY_CATEGORY[sc.category].push(sc); SUBCATEGORY_LABEL_LOOKUP[sc.category + '::' + sc.key] = sc.label; }); } } catch (e) { console.warn('[EverShelf] Could not load subcategories:', e); } } function getSubcategoryOptionsHtml(category, selectedValue = '') { const list = SUBCATEGORIES_BY_CATEGORY[category]; if (!list || list.length === 0) return ''; const opts = list.map(sc => `` ).join(''); return '' + opts; } function updateSubcategoryField(selectId, groupId, category, selectedValue = '') { const select = document.getElementById(selectId); const group = document.getElementById(groupId); if (!select) return; const list = SUBCATEGORIES_BY_CATEGORY[category]; select.innerHTML = getSubcategoryOptionsHtml(category, selectedValue); if (group) { group.style.display = 'block'; const mark = group.querySelector('.subcategory-required-mark'); if (mark) { mark.style.color = '#e74c3c'; mark.style.display = REQUIRED_SUBCATEGORY_CATEGORIES.includes(category) ? 'inline' : 'none'; } } } function onEditActionCategoryChange() { const cat = document.getElementById('edit-action-category')?.value || ''; updateSubcategoryField('edit-action-subcategory', 'edit-action-subcategory-group', cat); } // 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|asiago|fontina|emmental|gruyere|scamorza|groviera/.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\s+di\s+(riso|pasta|farro|orzo|couscous)/.test(name)) days = 7; 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 = 30; // whole tubers in a bag, pantry: 3-5 weeks 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, 30); 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; // Dairy opened outside fridge: spoils very quickly at room temperature if (/\bpanna\b/.test(name)) return 3; if (/\b(yogurt|yaourt|yoghurt)\b/.test(name)) return 2; if (/\blatte\b/.test(name)) return 1; if (/\bformaggio\b/.test(name)) return 2; 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 7; // generic: default to UHT (most common in IT households) if (/\b(yogurt|yaourt|yoghurt)\b/.test(name)) return 5; if (/mozzarella|burrata|stracciatella/.test(name)) return 3; if (/philadelphia|spalmabile/.test(name)) return 7; // Specific hard cheeses that contain 'fresco' in their commercial name (e.g. Asiago fresco) // must be matched BEFORE the generic 'formaggio fresco' catch-all if (/parmigiano|grana|pecorino|provolone|asiago|fontina|emmental|gruyere|scamorza|groviera/.test(name)) return 28; if (/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 5; 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\s+di\s+(riso|pasta|farro|orzo|couscous)/.test(name)) return 7; if (/insalata|rucola|spinaci|lattuga|crescione|germogli/.test(name)) return 4; 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 in fridge (opened pack, not necessarily cut) if (/\bavocado\b/.test(name)) return 3; if (/\b(fragola|fragole|lampone|lamponi|mirtillo|mirtilli|mora|more)\b/.test(name)) return 4; if (/\b(banana|banane|pesca|pesche|albicocca|albicocche|ciliegia|ciliegie|mango|papaya)\b/.test(name)) return 4; if (/\b(mela|mele|pera|pere|nettarina|prugna|kiwi|ananas|uva|melone|anguria)\b/.test(name)) return 5; if (/\b(arancia|arance|mandarino|mandarini|pompelmo|clementina|limone|limoni)\b/.test(name)) return 7; // Vegetables in fridge (opened pack) if (/\b(zucchina|zucchine|melanzana|melanzane|pomodor)\b/.test(name)) return 5; if (/\b(peperone|peperoni)\b/.test(name)) return 5; if (/\b(broccolo|broccoli|cavolfiore|cavolo)\b/.test(name)) return 4; if (/\bsedano\b|\bfinocchio\b/.test(name)) return 5; if (/\b(cipolla|cipolle|cipollotto|scalogno|porro)\b/.test(name)) return 6; if (/\b(carota|carote)\b/.test(name)) return 7; if (/\b(patata|patate|tubero)\b/.test(name)) return 4; if (/\baglio\b/.test(name)) return 14; // ── F.extra: Bread in fridge (opened) ──────────────────────────────── // Thin flatbreads (piadina, crescia, tigella) get mold very quickly if (/\b(piadina|piadelle?|crescia|tigella)\b/.test(name)) return 2; // Packaged sliced bread — preservatives help a bit if (/\b(bauletto|pancarr[eè]|pan\s+carr[eè]?|tramezzin)\b/.test(name)) return 4; // Generic bread in fridge if (/\bpane\b/.test(cat)) return 3; // ── 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 180; 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 = 2; // always 2x let _torchActive = false; let _aiVisualGen = 0; // bumped on each new scan session — stale AI responses are ignored let _scannerPaused = false; // pause decode loops without stopping the camera stream let _scannerGen = 0; let _detectorNative = null; let _detectorZbar = null; let _barcodeEnginesReady = null; let _tesseractWorker = null; let _tesseractInitPromise = null; const _LOCAL_OCR_DELAY_MS = 2500; // local digit OCR before AI const _SCAN_FORMATS = ['ean_13', 'ean_8', 'code_128', 'code_39', 'upc_a', 'upc_e']; // Apply fixed 2x zoom (hardware if available, CSS fallback) async function _applyFixedZoom() { if (!scannerStream) return; const track = scannerStream.getVideoTracks()[0]; if (!track) return; const caps = track.getCapabilities ? track.getCapabilities() : {}; if (caps.zoom && caps.zoom.max >= 2) { const z = Math.min(caps.zoom.max, caps.zoom.min * 2); try { await track.applyConstraints({ advanced: [{ zoom: z }] }); scanLog(`HW zoom: ${z}`); } catch(e) {} } else { const video = document.getElementById('scanner-video'); if (video) video.style.transform = 'scale(2)'; scanLog('SW zoom: scale(2)'); } } async function toggleTorch() { if (!scannerStream) return; const track = scannerStream.getVideoTracks()[0]; if (!track) return; const caps = track.getCapabilities ? track.getCapabilities() : {}; if (!caps.torch) { showToast(t('scan.torch_unavailable'), 'info'); return; } _torchActive = !_torchActive; try { await track.applyConstraints({ advanced: [{ torch: _torchActive }] }); const btn = document.getElementById('scan-torch-btn'); if (btn) btn.classList.toggle('torch-on', _torchActive); showToast(_torchActive ? t('scan.torch_on') : t('scan.torch_off'), 'info'); } catch(e) { showToast(t('scan.torch_unavailable'), 'info'); _torchActive = false; } } async function flipCamera() { const s = getSettings(); const current = s.camera_facing || 'environment'; const next = current === 'environment' ? 'user' : 'environment'; s.camera_facing = next; _settingsCache = s; _saveSettingToServer({ camera_facing: next }); showToast(next === 'user' ? t('scan.flip_front') : t('scan.flip_back'), 'info'); stopScanner(); setTimeout(() => initScanner(), 150); } // ===== SCAN TAB SWITCHING ===== function switchScanTab(tab) { ['barcode','name','ai'].forEach(id => { const btn = document.getElementById(`scan-tab-${id}`); const content = document.getElementById(`scan-tabcontent-${id}`); const active = id === tab; if (btn) btn.classList.toggle('active', active); if (content) content.style.display = active ? '' : 'none'; }); // Spesa: no manual barcode field — never focus that input if (tab === 'barcode') { if (_spesaMode) return; const el = document.getElementById('manual-barcode-input'); if (el) setTimeout(() => el.focus(), 80); } else if (tab === 'name') { const el = document.getElementById('quick-product-name'); if (el) setTimeout(() => el.focus(), 80); } } // ===== SCAN HISTORY (server-synced via app_settings key "scan_history") ===== const _SCAN_HISTORY_MAX = 20; function addToScanRecents(product) { if (!product || !product.id) return; let list = (_scanHistoryCache || []).filter(r => r.id !== product.id); list.unshift({ id: product.id, barcode: product.barcode || '', name: product.name, brand: product.brand || '', category: product.category || '', ts: Date.now() }); if (list.length > _SCAN_HISTORY_MAX) list = list.slice(0, _SCAN_HISTORY_MAX); _scanHistoryCache = list; _saveToServer('scan_history', list); } async function updateScanRecents() { let list = (_scanHistoryCache || []).slice(0, 6); if (_spesaMode && list.length > 0) { try { const data = await api('inventory_list'); const stocked = new Set((data.inventory || []).filter(i => parseFloat(i.quantity) > 0).map(i => i.product_id)); list = list.filter(r => stocked.has(r.id)); } catch (_) { /* keep list on error */ } } const wrap = document.getElementById('scan-recents'); const chips = document.getElementById('scan-recents-chips'); if (!wrap || !chips) return; if (list.length === 0) { wrap.style.display = 'none'; return; } wrap.style.display = 'flex'; chips.innerHTML = list.map(r => { const icon = CATEGORY_ICONS[mapToLocalCategory(r.category, r.name)] || '📦'; const label = escapeHtml(r.name) + (r.brand ? ` ${escapeHtml(r.brand)}` : ''); return ``; }).join(''); } async function _productHasLiveStock(productId) { try { const data = await api('inventory_list'); return (data.inventory || []).some(i => i.product_id == productId && parseFloat(i.quantity) > 0); } catch (_) { return true; } } async function _selectRecentProduct(productId) { showLoading(true); try { if (_spesaMode && !(await _productHasLiveStock(productId))) { showLoading(false); showToast(t('error.not_in_inventory'), 'error'); return; } const data = await api('product_get', { id: productId }); if (data.product) { currentProduct = data.product; if (!currentProduct.weight_info && currentProduct.notes) { const m = currentProduct.notes.match(/Peso:\s*([^·]+)/); if (m) currentProduct.weight_info = m[1].trim(); } showLoading(false); stopScanner(); showProductAction(); } else { showLoading(false); showToast(t('error.not_found'), 'error'); } } catch(e) { showLoading(false); showToast(t('error.connection'), 'error'); } } // ===== SCAN LIVE CODE / CONFIRM OVERLAY ===== let _liveCodeTimer = null; function _showScanLiveCode(code) { const el = document.getElementById('scan-live-code'); if (!el) return; el.textContent = code; el.style.display = 'block'; clearTimeout(_liveCodeTimer); _liveCodeTimer = setTimeout(() => { if (el) el.style.display = 'none'; }, 1500); } function _hideScanLiveCode() { const el = document.getElementById('scan-live-code'); if (el) { el.style.display = 'none'; clearTimeout(_liveCodeTimer); } } function _showScanConfirm(name) { const overlay = document.getElementById('scan-confirm-overlay'); const nameEl = document.getElementById('scan-confirm-name'); if (!overlay) return; if (nameEl) nameEl.textContent = name || ''; overlay.style.display = 'flex'; setTimeout(() => { if (overlay) overlay.style.display = 'none'; }, 900); } // ===== AI NUMBER OCR (Gemini reads printed barcode digits) ===== let _numOcrRunning = false; async function _tryGeminiNumberOCR(options = {}) { const { chainToVisual = false } = options; if (_numOcrRunning || !_requireGemini()) return; const video = document.getElementById('scanner-video'); if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; } _numOcrRunning = true; _updateScanAiButton(); const btn = document.getElementById('scan-num-ocr-btn'); if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); } _setScanStatus(t('scan.status_ocr_searching'), 'retry', t('scan.method_ai_ocr')); try { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext('2d').drawImage(video, 0, 0); const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1]; const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 }); if (result.barcode) { scanLog(`AI OCR: found barcode ${result.barcode}`); showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success'); onBarcodeDetected(result.barcode); } else { scanLog('AI OCR: barcode digits not found'); if (chainToVisual && scannerStream && _scannerAiAllowed()) { scanLog('AI OCR failed — switching to visual product identification'); _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision')); await _tryGeminiVisualBarcode(); } else { showToast(t('scan.num_ocr_not_found'), 'warning'); _setScanStatus(t('scan.status_scanning'), '', ''); resumeScanner(); } } } catch(e) { scanLog(`AI OCR error: ${e.message}`); if (chainToVisual && scannerStream && _scannerAiAllowed()) { _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision')); await _tryGeminiVisualBarcode(); } else { _setScanStatus(t('scan.status_scanning'), '', ''); resumeScanner(); } showToast(t('error.connection'), 'error'); } finally { _numOcrRunning = false; _updateScanAiButton(); if (btn) { btn.disabled = false; btn.textContent = t('scan.num_ocr_btn'); } } } // ===== AI VISUAL PRODUCT IDENTIFICATION (manual button only) ===== let _aiBarcodeVisualRunning = false; let _aiDetectedProductDraft = null; let _aiInventoryCandidates = []; let _aiFinishedCandidates = []; let _aiCatalogCandidates = []; function _showScanAiOverlay(msg) { const el = document.getElementById('scan-ai-overlay'); const msgEl = document.getElementById('scan-ai-overlay-msg'); if (el) el.style.display = 'flex'; if (msgEl) msgEl.textContent = msg || ''; } function _hideScanAiOverlay() { const el = document.getElementById('scan-ai-overlay'); if (el) el.style.display = 'none'; } /** Invalidate in-flight AI responses when starting a new scan session. */ function _resetAiFallbackForNewScan() { _aiVisualGen++; _hideScanAiOverlay(); _updateScanAiButton(); } /** Manual AI button — shown only while scanner is active and Gemini is online. */ function _updateScanAiButton() { const btn = document.getElementById('scan-ai-manual-btn'); if (!btn) return; btn.textContent = t('scan.ai_manual_btn'); const show = _currentPageId === 'scan' && !!scannerStream && _scannerAiAllowed() && !_aiBarcodeVisualRunning && !_numOcrRunning; btn.style.display = show ? '' : 'none'; btn.disabled = !!(_aiBarcodeVisualRunning || _numOcrRunning || _aiDetectedProductDraft); } function _clearAiMatchPanel() { const result = document.getElementById('scan-result'); if (!result) return; result.style.display = 'none'; result.innerHTML = ''; _aiDetectedProductDraft = null; _aiInventoryCandidates = []; _aiFinishedCandidates = []; _aiCatalogCandidates = []; _updateScanAiButton(); } function _renderAiCandidateThumb(item) { const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦'; if (item.image_url) { return ``; } return `${catIcon}`; } function _renderAiCandidateRow(item, idx, kind) { const locInfo = item.locations ? String(item.locations).split(',').map(l => (LOCATIONS[l.trim()] || { label: l.trim() }).label).join(', ') : (LOCATIONS[item.location]?.label || item.location || ''); let meta = item.brand || ''; if (kind === 'stock' && item.total_qty != null) { meta = [meta, `${parseFloat(item.total_qty)} ${item.unit || ''}`.trim(), locInfo].filter(Boolean).join(' · '); } else if (kind === 'finished') { const locLabel = locInfo ? `${locInfo}` : ''; const qtyHint = item.expected_qty ? `${item.expected_qty} ${item.unit || ''}`.trim() : ''; meta = [meta, t('scan.ai_match_finished_badge'), locLabel, qtyHint].filter(Boolean).join(' · '); } else { meta = [meta, item.barcode ? `…${String(item.barcode).slice(-4)}` : ''].filter(Boolean).join(' · '); } return ` `; } function _renderAiCandidateSection(title, items, kind) { if (!items || !items.length) return ''; const rows = items.map((it, i) => _renderAiCandidateRow(it, i, kind)).join(''); return `
${escapeHtml(title)}
${rows}
`; } function _showAiMatchChoices(aiProduct) { const result = document.getElementById('scan-result'); if (!result) return; const aiName = aiProduct?.name || t('product.not_recognized'); const aiBrand = aiProduct?.brand || ''; const catIcon = CATEGORY_ICONS[mapToLocalCategory(aiProduct?.category || '', aiName)] || '📦'; const inStock = (_aiInventoryCandidates || []).filter(i => parseFloat(i.total_qty) > 0); const finished = _spesaMode ? [] : (_aiFinishedCandidates || []); const catalog = _spesaMode ? [] : (_aiCatalogCandidates || []); const hasMatches = inStock.length + finished.length + catalog.length > 0; const addLabel = t('scan.ai_match_add_btn').replace('{name}', aiName); result.innerHTML = `
${catIcon}
${t('scan.ai_match_title')}
${escapeHtml(aiName)}
${aiBrand ? `
${escapeHtml(aiBrand)}
` : ''}

${escapeHtml(t('scan.ai_match_action_hint'))}

${hasMatches ? `

${escapeHtml(t('scan.ai_match_or_similar'))}

` : `
${t('scan.ai_match_none')}
`} ${_renderAiCandidateSection(t('scan.ai_match_existing'), inStock, 'stock')} ${_renderAiCandidateSection(t('scan.ai_match_finished'), finished, 'finished')} ${_renderAiCandidateSection(t('scan.ai_match_catalog'), catalog, 'catalog')}
`; result.style.display = 'block'; setTimeout(() => result.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80); } async function _fetchAiMatchCandidates(aiProduct) { const q = (aiProduct?.name || '').trim(); if (q.length < 2) { return { in_stock: [], finished: [], catalog: [] }; } try { const res = await api('ai_product_suggest', {}, 'POST', { q, brand: aiProduct?.brand || '', limit: 5 }); if (!res?.success) return { in_stock: [], finished: [], catalog: [] }; return res; } catch (_) { return { in_stock: [], finished: [], catalog: [] }; } } async function _confirmAiDetectedProduct() { const p = _aiDetectedProductDraft; if (!p) return; showLoading(true); try { const saveResult = await api('product_save', {}, 'POST', { barcode: '', name: p.name || t('product.not_recognized'), brand: p.brand || '', category: p.category || '', image_url: '', unit: 'pz', default_quantity: 1, package_unit: '', notes: '', }); const newId = saveResult?.id; if (saveResult?.success && newId) { const full = await api('product_get', { id: newId }).catch(() => null); currentProduct = full?.product || { id: newId, barcode: '', name: p.name || t('product.not_recognized'), brand: p.brand || '', category: p.category || '', image_url: '', unit: 'pz', default_quantity: 1, package_unit: '', _confCount: 0, weight_info: '', }; if (saveResult.merged) { showToast(t('scan.ai_match_merged_existing'), 'info'); } addToScanRecents(currentProduct); _clearAiMatchPanel(); showLoading(false); setTimeout(() => showProductAction(), 250); } else { showLoading(false); const errKey = saveResult?.error; const msg = errKey === 'offline' ? t('error.network') : errKey === 'unauthorized' ? t('error.connection') : (errKey || saveResult?.message || t('error.save')); console.error('[AI add] product_save', saveResult); showToast(msg, 'error'); } } catch (err) { showLoading(false); console.error('[AI add]', err); showToast(err?.message || t('error.connection'), 'error'); } } async function _selectAiProductCandidate(kind, idx) { const lists = { stock: _aiInventoryCandidates, finished: _aiFinishedCandidates, catalog: _aiCatalogCandidates }; const p = (lists[kind] || [])[idx]; if (!p?.id) return; showLoading(true); try { const full = await api('product_get', { id: p.id }); if (!full?.product) { showLoading(false); showToast(t('error.not_found'), 'error'); return; } currentProduct = full.product; if (currentProduct.notes) { const pesoMatch = currentProduct.notes.match(/Peso:\s*([^·]+)/); if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim(); } currentProduct._confCount = 0; const hasStock = kind === 'stock' ? await _productHasLiveStock(p.id) : false; addToScanRecents(currentProduct); _clearAiMatchPanel(); showLoading(false); if (kind !== 'stock' || !hasStock) { if (_spesaMode || kind === 'stock') { showToast(t('error.not_in_inventory'), 'info'); } else if (kind === 'finished') { showToast(t('scan.ai_match_finished_hint'), 'info'); } setTimeout(() => showAddForm(), 250); return; } setTimeout(() => showProductAction(), 250); } catch (err) { showLoading(false); console.error('[AI select]', err); showToast(err?.message || t('error.connection'), 'error'); } } async function _triggerManualAiScan() { if (!_scannerAiAllowed()) { showToast(t('error.no_api_key'), 'error'); return; } if (_aiBarcodeVisualRunning || _numOcrRunning) return; _clearAiMatchPanel(); if (_spesaMode) { await _tryGeminiVisualBarcode(); } else { await _tryGeminiNumberOCR({ chainToVisual: true }); } } async function _tryGeminiVisualBarcode() { if (_aiBarcodeVisualRunning || !_requireGemini()) return; const video = document.getElementById('scanner-video'); if (!video || !video.videoWidth) return; // ★ Capture the frame BEFORE stopping the stream — after stopScanner() the // video element is blanked and drawImage would send a black image to Gemini. const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext('2d').drawImage(video, 0, 0); const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1]; if (!imageBase64) { scanLog('AI visual: failed to capture frame'); return; } _aiBarcodeVisualRunning = true; _updateScanAiButton(); const myGen = _aiVisualGen; pauseScanner(); // keep camera stream alive — only pause decode while AI processes _setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision')); _showScanAiOverlay(t('scan.ai_overlay_msg')); try { const result = await api('gemini_barcode_visual', {}, 'POST', { image: imageBase64, lang: _currentLang || 'it', }); if (myGen !== _aiVisualGen) { scanLog('AI visual: stale response ignored'); _hideScanAiOverlay(); resumeScanner(); return; } if (result.found && result.product) { const p = result.product; scanLog(`AI visual: found "${p.name}" (${p.brand})`); _hideScanAiOverlay(); showToast(t('scan.ai_fallback_found'), 'success'); _aiDetectedProductDraft = { name: p.name || t('product.not_recognized'), brand: p.brand || '', category: p.category || '', }; const matchRes = await _fetchAiMatchCandidates(_aiDetectedProductDraft); if (myGen !== _aiVisualGen) { scanLog('AI visual: stale match response ignored'); _hideScanAiOverlay(); resumeScanner(); return; } _aiInventoryCandidates = matchRes.in_stock || []; _aiFinishedCandidates = matchRes.finished || []; _aiCatalogCandidates = matchRes.catalog || []; _showAiMatchChoices(_aiDetectedProductDraft); resumeScanner(); } else { if (myGen !== _aiVisualGen) { scanLog('AI visual: stale miss ignored'); _hideScanAiOverlay(); resumeScanner(); return; } scanLog('AI visual: product not identified'); _hideScanAiOverlay(); showToast(t('scan.ai_not_recognized'), 'warning'); _setScanStatus(t('scan.status_scanning'), '', ''); resumeScanner(); } } catch (e) { if (myGen !== _aiVisualGen) { scanLog('AI visual: stale error ignored'); _hideScanAiOverlay(); resumeScanner(); return; } scanLog(`AI visual error: ${e.message}`); _hideScanAiOverlay(); showToast(t('scan.ai_not_recognized'), 'warning'); _setScanStatus(t('scan.status_scanning'), '', ''); resumeScanner(); } finally { _aiBarcodeVisualRunning = false; _updateScanAiButton(); } } // ===== 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) { // Settings come from server — do NOT read from localStorage (per-device storage). // _settingsCache is populated by _applySyncedSettings() on app init. _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; // Cache dark_mode in localStorage ONLY as a hint for the pre-render _earlyTheme() IIFE // (prevents flash before server fetch). Authoritative value is in server .env. try { localStorage.setItem('evershelf_dark_mode', settings.dark_mode || 'auto'); } catch(_) {} // Persist user-prefs subset to DB _settingsDirty = true; _debouncedSyncSettings(); } /** Save one or more settings directly to server .env (partial update). */ async function _saveSettingToServer(data) { try { await api('save_settings', {}, 'POST', data); } catch(e) { /* offline */ } } 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 (only when not already done via _applySyncedSettings) const serverSettings = await api('get_settings'); _applySyncedSettings(serverSettings); // Load all server-persisted user data from SQLite app_settings const res = await api('app_settings_get'); if (res.success && res.settings) { const srv = res.settings; if (srv.review_confirmed) _reviewConfirmedCache = srv.review_confirmed; if (Array.isArray(srv.subcategory_required_categories)) REQUIRED_SUBCATEGORY_CATEGORIES = srv.subcategory_required_categories; // meal_plan is stored in SQLite app_settings so all devices stay in sync if (srv.meal_plan) { const s = getSettings(); s.meal_plan = srv.meal_plan; _settingsCache = s; if (document.getElementById('meal-plan-grid')) renderMealPlanEditor(); } // tts_voice preference (best-effort cross-device — falls back if voice unavailable) if (srv.tts_voice) { const s = getSettings(); if (!s.tts_voice) { s.tts_voice = srv.tts_voice; _settingsCache = s; } } // ── User data previously stored in localStorage, now server-synced ── if (srv.scan_history) _scanHistoryCache = srv.scan_history; if (srv.shopping_tags) _shoppingTagsCache = srv.shopping_tags; if (srv.pinned_bring) _pinnedBringCache = srv.pinned_bring; if (srv.pref_use_loc) _prefUseLocCache = srv.pref_use_loc; if (srv.pref_move_loc) _prefMoveLocCache = srv.pref_move_loc; if (srv.auto_added_bring) _autoAddedBringCache = srv.auto_added_bring; if (srv.bring_blocklist) _bringBlocklistCache = srv.bring_blocklist; if (srv.no_expiry_dismissed) _noExpiryDismissedCache = srv.no_expiry_dismissed; if (srv.family_sibling_confirmed) _familySiblingConfirmedCache = srv.family_sibling_confirmed; // ── One-time migration: if server has nothing yet, seed from old localStorage ── if (!srv.shopping_tags) { try { const v = localStorage.getItem('shopping_tags'); if (v) { _shoppingTagsCache = JSON.parse(v); _saveToServer('shopping_tags', _shoppingTagsCache); localStorage.removeItem('shopping_tags'); } } catch(_) {} } if (!srv.pinned_bring) { try { const v = localStorage.getItem('_userPinnedBring'); if (v) { _pinnedBringCache = JSON.parse(v); _saveToServer('pinned_bring', _pinnedBringCache); localStorage.removeItem('_userPinnedBring'); } } catch(_) {} } if (!srv.pref_use_loc) { try { const v = localStorage.getItem('_prefUseLoc'); if (v) { _prefUseLocCache = JSON.parse(v); _saveToServer('pref_use_loc', _prefUseLocCache); localStorage.removeItem('_prefUseLoc'); } } catch(_) {} } if (!srv.pref_move_loc) { try { const v = localStorage.getItem('_prefMoveLoc'); if (v) { _prefMoveLocCache = JSON.parse(v); _saveToServer('pref_move_loc', _prefMoveLocCache); localStorage.removeItem('_prefMoveLoc'); } } catch(_) {} } if (!srv.auto_added_bring) { try { const v = localStorage.getItem('_autoAddedBring'); if (v) { _autoAddedBringCache = JSON.parse(v); _saveToServer('auto_added_bring', _autoAddedBringCache); localStorage.removeItem('_autoAddedBring'); } } catch(_) {} } if (!srv.bring_blocklist) { try { const v = localStorage.getItem('_bringPurchasedBlocklist'); if (v) { _bringBlocklistCache = JSON.parse(v); _saveToServer('bring_blocklist', _bringBlocklistCache); localStorage.removeItem('_bringPurchasedBlocklist'); } } catch(_) {} } if (!srv.no_expiry_dismissed) { try { const v = localStorage.getItem('_noExpiryDismissed'); if (v) { _noExpiryDismissedCache = JSON.parse(v); _saveToServer('no_expiry_dismissed', _noExpiryDismissedCache); localStorage.removeItem('_noExpiryDismissed'); } } catch(_) {} } if (!srv.scan_history) { try { const v = localStorage.getItem('evershelf_scan_recents'); if (v) { _scanHistoryCache = JSON.parse(v); _saveToServer('scan_history', _scanHistoryCache); localStorage.removeItem('evershelf_scan_recents'); } } catch(_) {} } } } catch(e) { /* offline — in-memory caches stay at their defaults */ } } /** * Apply server settings object into in-memory cache (_settingsCache). * Called both from _initApp (to reuse an already-fetched response) and syncSettingsFromDB. */ function _applySyncedSettings(serverSettings) { if (!serverSettings) return; _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', 'tts_engine','tts_rate','tts_pitch','tts_auth_header_name','tts_auth_header_value','tts_extra_fields', 'screensaver_enabled','screensaver_timeout', 'price_enabled','price_country','price_currency','price_update_months', 'zerowaste_tips_enabled', 'shopping_enabled','shopping_mode','shopping_smart_suggestions', 'shopping_forecast','shopping_auto_add_threshold', 'dark_mode', 'barcode_ai_fallback', // Home Assistant 'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events', 'ha_notify_service','ha_expiry_days']; 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; // Update localStorage hint for _earlyTheme() IIFE on next load try { localStorage.setItem('evershelf_dark_mode', s.dark_mode || 'auto'); } catch(_) {} } } let _infoTabTimer = null; let _backupTabTimer = null; /** * Load the Info tab: Gemini token usage + cost, log size, DB size, log level. * Called on tab click; auto-refreshes every 30s while the tab is open. */ // ── Backup Tab ──────────────────────────────────────────────────────────────── async function _loadBackupTab() { if (_backupTabTimer) { clearInterval(_backupTabTimer); _backupTabTimer = null; } await _renderBackupTab(); // Pull server settings to populate inputs if not yet loaded try { const ss = await api('get_settings'); if (ss) { const bkRetEl = document.getElementById('setting-backup-retention-days'); if (bkRetEl) { bkRetEl.value = ss.backup_retention_days || 3; bkRetEl.dataset.loaded = '1'; } const gdriveEnEl = document.getElementById('setting-gdrive-enabled'); if (gdriveEnEl) gdriveEnEl.checked = !!ss.gdrive_enabled; const gdriveFolderEl = document.getElementById('setting-gdrive-folder-id'); if (gdriveFolderEl) { gdriveFolderEl.value = ss.gdrive_folder_id || ''; gdriveFolderEl.dataset.loaded = '1'; } const gdriveRetEl = document.getElementById('setting-gdrive-retention-days'); if (gdriveRetEl) { gdriveRetEl.value = ss.gdrive_retention_days || 30; gdriveRetEl.dataset.loaded = '1'; } // Pre-fill client_id (never show secret back) if (ss.gdrive_client_id_set) { const ciEl = document.getElementById('setting-gdrive-client-id'); if (ciEl && !ciEl.value) ciEl.placeholder = '● ● ● already configured ● ● ●'; } // OAuth token status const oauthStatusEl = document.getElementById('gdrive-oauth-token-status'); if (oauthStatusEl) { oauthStatusEl.textContent = ss.gdrive_refresh_token_set ? ('✅ ' + (t('settings.backup.gdrive_oauth_authorized') || 'Authorized')) : ('⚠️ ' + (t('settings.backup.gdrive_oauth_not_authorized') || 'Not authorized yet')); oauthStatusEl.style.color = ss.gdrive_refresh_token_set ? '#15803d' : '#b45309'; } // Redirect URI for OAuth setup — always http://localhost for self-hosted compat // (can be overridden server-side via GDRIVE_REDIRECT_URI env var) const rdEl = document.getElementById('gdrive-redirect-uri-display'); if (rdEl) rdEl.textContent = 'http://localhost'; } } catch(e) { /* non-critical */ } } async function _renderBackupTab() { const lastInfoEl = document.getElementById('backup-last-info'); const listEl = document.getElementById('backup-list-container'); try { const data = await api('backup_list'); if (!data || !data.success) { if (lastInfoEl) lastInfoEl.innerHTML = 'Error loading backup info'; return; } // Last backup info if (lastInfoEl) { if (data.last_backup_ts) { const secsAgo = Math.floor(Date.now() / 1000) - data.last_backup_ts; let ago; if (secsAgo < 120) ago = secsAgo < 5 ? t('time.just_now') : t('time.seconds_ago', { n: secsAgo }); else if (secsAgo < 3600) ago = t('time.minutes_ago', { n: Math.floor(secsAgo / 60) }); else if (secsAgo < 86400) ago = t('time.hours_ago', { n: Math.floor(secsAgo / 3600) }); else ago = t('time.days_ago', { n: Math.floor(secsAgo / 86400) }); const name = data.last_backup_file || ''; lastInfoEl.innerHTML = `${t('settings.backup.last_backup') || 'Ultimo backup'}: ${ago} (${name})`; } else { lastInfoEl.innerHTML = `${t('settings.backup.no_backup_yet') || 'Nessun backup ancora'}`; } } // Backup list if (listEl) { if (!data.backups || data.backups.length === 0) { listEl.innerHTML = `

${t('settings.backup.list_empty') || 'Nessun backup disponibile'}

`; } else { const rows = data.backups.map(b => { const d = new Date(b.created_at); const dateStr = d.toLocaleString(); return `
${b.filename} ${b.size_kb} KB · ${dateStr}
`; }).join(''); listEl.innerHTML = `

${t('settings.backup.retention_info') || ''} ${data.retention_days} ${t('settings.backup.retention_days') || 'gg'}

${rows}`; } } } catch(e) { if (lastInfoEl) lastInfoEl.innerHTML = 'Error: ' + e.message + ''; } } async function _backupNow() { const btn = document.getElementById('btn-backup-now'); const statusEl = document.getElementById('backup-status'); if (btn) btn.disabled = true; if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = t('settings.backup.backing_up') || '⏳ Backup in corso…'; statusEl.style.display = 'block'; } try { const r = await api('backup_now'); if (r && r.success) { if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${r.filename} (${r.size_kb} KB)`; } await _renderBackupTab(); } else { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } } } catch(e) { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } } finally { if (btn) btn.disabled = false; if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 5000); } } async function _backupDelete(filename) { if (!confirm(`${t('settings.backup.delete_confirm') || 'Eliminare il backup'} ${filename}?`)) return; const r = await api('backup_delete', {}, 'POST', { filename }); if (r && r.success) await _renderBackupTab(); else alert(`❌ ${r?.error || 'Error deleting backup'}`); } async function _backupRestore(filename) { if (!confirm(`${t('settings.backup.restore_confirm') || 'Ripristinare il backup'} "${filename}"?\n\n⚠️ ATTENZIONE: tutti i dati attuali verranno SOSTITUITI. Questa azione è irreversibile.`)) return; const statusEl = document.getElementById('backup-status'); if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Ripristino in corso…'; statusEl.style.display = 'block'; } try { const r = await api('backup_restore', {}, 'POST', { filename }); if (r && r.success) { alert(`✅ ${r.message || 'Ripristino completato!'}\n\nLa pagina verrà ricaricata.`); location.reload(); } else { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } } } catch(e) { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } } } async function _gdriveTest() { const btn = document.getElementById('btn-gdrive-test'); const statusEl = document.getElementById('gdrive-test-status'); if (btn) btn.disabled = true; if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Test connessione…'; statusEl.style.display = 'block'; } try { // Save current settings first so the server has the latest JSON/folder await saveSettings(); const r = await api('gdrive_test'); if (r && r.success) { if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${t('settings.backup.gdrive_ok') || 'Connessione riuscita!'}`; } } else { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } } } catch(e) { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } } finally { if (btn) btn.disabled = false; if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 6000); } } async function _gdrivePushNow() { const btn = document.getElementById('btn-gdrive-push'); const statusEl = document.getElementById('gdrive-test-status'); if (btn) btn.disabled = true; if (statusEl) { statusEl.className = 'settings-status'; statusEl.textContent = t('settings.backup.gdrive_pushing') || '⏳ Upload in corso…'; statusEl.style.display = 'block'; } try { await saveSettings(); const r = await api('gdrive_push'); if (r && r.success) { if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = `✅ ${r.filename} → Drive (purged: ${r.purged_remote || 0})`; } } else { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${r?.error || 'Error'}`; } } } catch(e) { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = `❌ ${e.message}`; } } finally { if (btn) btn.disabled = false; if (statusEl) setTimeout(() => { statusEl.style.display = 'none'; }, 6000); } } async function _gdriveAuthorize() { const btn = document.getElementById('btn-gdrive-authorize'); if (btn) btn.disabled = true; try { await saveSettings(); const r = await api('gdrive_oauth_url'); if (r && r.success) { window.open(r.url, '_blank', 'width=600,height=700,noopener'); // Store redirect_uri used so gdrive_oauth_exchange can match it window._gdriveLastRedirectUri = r.redirect_uri || 'http://localhost'; // Show manual code input section const codeSection = document.getElementById('gdrive-code-section'); if (codeSection) codeSection.style.display = ''; const statusEl = document.getElementById('gdrive-oauth-token-status'); if (statusEl) { statusEl.textContent = t('settings.backup.gdrive_oauth_window_opened') || '🔑 Authorization page opened — authorize and paste the URL below'; statusEl.style.color = '#2563eb'; } } else { alert('❌ ' + (r?.error || 'Failed to get OAuth URL')); } } catch(e) { alert('❌ ' + e.message); } finally { if (btn) btn.disabled = false; } } async function _gdriveSubmitCode() { const inputEl = document.getElementById('gdrive-code-input'); const btn = document.getElementById('btn-gdrive-submit-code'); const raw = (inputEl?.value || '').trim(); if (!raw) { alert(t('settings.backup.gdrive_code_empty') || 'Paste the URL or code first'); return; } // Accept either a full URL (extract code param) or just the bare code let code = raw; try { const u = new URL(raw); const c = u.searchParams.get('code'); if (c) code = c; } catch(e) { /* not a URL, use as-is */ } if (btn) btn.disabled = true; try { const r = await api('gdrive_oauth_exchange', null, 'POST', { code, redirect_uri: window._gdriveLastRedirectUri || 'http://localhost' }); if (r && r.success) { const statusEl = document.getElementById('gdrive-oauth-token-status'); if (statusEl) { statusEl.textContent = '✅ ' + (t('settings.backup.gdrive_oauth_authorized') || 'Authorized'); statusEl.style.color = '#15803d'; } const codeSection = document.getElementById('gdrive-code-section'); if (codeSection) codeSection.style.display = 'none'; if (inputEl) inputEl.value = ''; } else { alert('❌ ' + (r?.error || 'Code exchange failed')); } } catch(e) { alert('❌ ' + e.message); } finally { if (btn) btn.disabled = false; } } async function _loadInfoTab() { // Cancel any previous auto-refresh if (_infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; } if (_backupTabTimer) { clearInterval(_backupTabTimer); _backupTabTimer = null; } await _renderInfoTab(); // Auto-refresh every 30s while Info tab is visible _infoTabTimer = setInterval(_renderInfoTab, 30_000); } async function _renderInfoTab() { const aiEl = document.getElementById('info-ai-content'); const sysEl = document.getElementById('info-system-content'); if (!aiEl && !sysEl) return; try { const d = await api('gemini_usage'); const s = getSettings(); // ── Locale & helpers ───────────────────────────────────────────────── const langMap = {it:'it-IT', en:'en-US', de:'de-DE', fr:'fr-FR', es:'es-ES'}; const locale = langMap[s.language] || langMap[navigator.language?.slice(0,2)] || 'it-IT'; const [yr, mo] = (d.month || '').split('-'); const monthLabel = new Intl.DateTimeFormat(locale, {month:'long', year:'numeric'}) .format(new Date(parseInt(yr), parseInt(mo)-1, 1)); // Cost → user currency const toCurr = (usd) => { if (!usd) return '—'; const c = s.price_currency || 'EUR'; let v = usd, sym = '$'; if (c === 'EUR') { v = usd * 0.92; sym = '€'; } else if (c === 'GBP') { v = usd * 0.79; sym = '£'; } else if (c === 'CHF') { v = usd * 0.90; sym = 'CHF '; } else if (c === 'CAD') { v = usd * 1.36; sym = 'CA$'; } else if (c === 'AUD') { v = usd * 1.54; sym = 'A$'; } else if (c === 'BRL') { v = usd * 5.20; sym = 'R$'; } else if (c === 'JPY') { v = usd * 155; sym = '¥'; } else if (c === 'SEK') { v = usd * 10.4; sym = 'kr'; } else if (c === 'NOK') { v = usd * 10.6; sym = 'kr'; } else if (c === 'DKK') { v = usd * 6.85; sym = 'kr'; } else if (c === 'PLN') { v = usd * 3.98; sym = 'zł'; } const decimals = (c === 'JPY') ? 1 : 4; return sym + v.toFixed(decimals); }; const fmtTok = n => n >= 1_000_000 ? (n/1_000_000).toFixed(2)+'M' : n >= 1_000 ? Math.round(n/1_000)+'K' : String(n||0); const fmtBytes = b => b > 1048576 ? (b/1048576).toFixed(1)+' MB' : b > 1024 ? Math.round(b/1024)+' KB' : (b||0)+' B'; const fmtDate = ts => ts ? new Intl.DateTimeFormat(locale, {day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit'}).format(new Date(ts*1000)) : '—'; const pill = (val, label, color='') => `
${val}
${label}
`; const sectionHeader = (label) => `
${label}
`; // ── AI Usage card ──────────────────────────────────────────────────── if (aiEl) { const ms = d.month_stats || {}; const ys = d.year_stats || {}; const hintEl = aiEl.closest('.settings-card')?.querySelector('.info-ai-subtitle'); if (hintEl) hintEl.textContent = t('settings.info.ai_overview'); const msIn = ms.input_tokens || 0; const msOut = ms.output_tokens || 0; const ysIn = ys.input_tokens || 0; const ysOut = ys.output_tokens || 0; // Month section const actionRows = Object.entries(ms.by_action || {}) .sort((a,b) => b[1]-a[1]).slice(0, 8) .map(([k,v]) => `${k}${v} ${t('settings.info.calls_unit')}`).join(''); const modelRows = Object.entries(ms.by_model || {}) .map(([m,mv]) => `${m}${fmtTok((mv.in||0)+(mv.out||0))}`).join(''); const monthHtml = `
${sectionHeader(monthLabel)}
${pill(ms.calls || 0, t('settings.info.ai_calls'))} ${pill('~'+fmtTok(msIn+msOut), t('settings.info.total_tokens'))} ${pill('~'+toCurr(ms.cost_usd), t('settings.info.est_cost'), '#15803d')}
${actionRows ? `
${t('settings.info.by_action')}${actionRows}
` : ''} ${modelRows ? `
${t('settings.info.by_model')}${modelRows}
` : ''}
`; // Year section const yearHtml = `
${sectionHeader(t('settings.info.year_label').replace('{year}', d.year))}
${pill('~'+(ys.calls || 0), t('settings.info.ai_calls'))} ${pill('~'+fmtTok(ysIn+ysOut), t('settings.info.total_tokens'))} ${pill('~'+toCurr(ys.cost_usd), t('settings.info.est_cost'), '#15803d')}
`; aiEl.innerHTML = monthHtml + yearHtml + `

${t('settings.info.pricing_note')}

`; } // ── Inventory card ─────────────────────────────────────────────────── const invEl = document.getElementById('info-inv-content'); if (invEl && d.db) { const db = d.db; invEl.innerHTML = `
${pill(db.inventory_active, t('settings.info.inv_active'))} ${pill(db.products_total, t('settings.info.inv_products'))} ${pill(db.expiring_soon, t('settings.info.inv_expiring'), db.expiring_soon > 0 ? '#d97706' : '')} ${pill(db.expired, t('settings.info.inv_expired'), db.expired > 0 ? '#dc2626' : '')} ${pill(db.finished, t('settings.info.inv_finished'))}
`; } // ── Activity card ──────────────────────────────────────────────────── const actEl = document.getElementById('info-act-content'); if (actEl && d.db) { const db = d.db; actEl.innerHTML = `
${pill(db.tx_month, t('settings.info.act_tx_month'))} ${pill(db.restock_month, t('settings.info.act_restock'))} ${pill(db.use_month, t('settings.info.act_use'))} ${pill(db.products_month, t('settings.info.act_new_products'))} ${pill(db.tx_year, t('settings.info.act_tx_year'))}
`; } // ── System card ────────────────────────────────────────────────────── if (sysEl) { const db = d.db || {}; const lvlColors = {DEBUG:'#1e40af//#dbeafe', INFO:'#15803d//#dcfce7', WARN:'#854d0e//#fef9c3', ERROR:'#991b1b//#fee2e2'}; const [lvlFg, lvlBg] = (lvlColors[d.log_level] || '#64748b//#f1f5f9').split('//'); sysEl.innerHTML = `
${pill(fmtBytes(db.bytes), t('settings.info.db_size'))} ${pill(fmtBytes(d.log_bytes), t('settings.info.log_size'))} ${pill(`${d.log_level||'INFO'}`, t('settings.info.log_level'))}
${t('settings.info.price_cache')} ${(d.caches?.price||0)} ${t('settings.info.cache_entries')}
${t('settings.info.last_backup')} ${d.last_backup_ts ? fmtDate(d.last_backup_ts)+' · '+fmtBytes(d.last_backup_bytes) : '—'}
`; } } catch(e) { ['info-ai-content','info-inv-content','info-act-content','info-system-content'].forEach(id => { const el = document.getElementById(id); if (el) el.innerHTML = `

${t('error.generic')}

`; }); } } /** * Populate the About section with the current app version from the server. */ async function _loadAboutSection() { const el = document.getElementById('about-version-label'); if (!el) return; try { const res = await api('check_update'); const manifest = await fetch('manifest.json?_=' + Date.now()).then(r => r.json()).catch(() => ({})); const local = manifest.version || '—'; const latest = res.latest_tag ? res.latest_tag.replace(/^v/, '') : null; el.textContent = 'v' + local + (latest && latest !== local ? ' → v' + latest + ' available' : ''); } catch(e) { el.textContent = '—'; } } /** * Manually triggered bug report from the About section in Settings. * Collects basic info and submits via the existing report_error endpoint. */ function reportBugManual() { const mc = document.getElementById('modal-content'); if (!mc) return; mc.innerHTML = `

${t('about.report_auto_info').replace('{version}', _loadedVersion || '—').replace('{lang}', _currentLang || '—')}

`; document.getElementById('modal-overlay').style.display = 'flex'; // Pill click: switch type, show/hide steps field mc.querySelectorAll('.bug-type-pill').forEach(pill => { pill.addEventListener('click', () => { mc.querySelectorAll('.bug-type-pill').forEach(p => p.classList.remove('active')); pill.classList.add('active'); const stepsGroup = document.getElementById('bug-form-steps-group'); if (stepsGroup) stepsGroup.style.display = (pill.dataset.btype === 'bug') ? '' : 'none'; }); }); } async function _submitBugReport() { const submitBtn = document.getElementById('bug-form-submit'); const statusEl = document.getElementById('bug-form-status'); const titleEl = document.getElementById('bug-form-title'); const descEl = document.getElementById('bug-form-desc'); const stepsEl = document.getElementById('bug-form-steps'); const activePill = document.querySelector('#modal-content .bug-type-pill.active'); const title = titleEl?.value.trim() || ''; const desc = descEl?.value.trim() || ''; const steps = stepsEl?.value.trim() || ''; const type = activePill?.dataset.btype || 'bug'; // Inline validation if (!title) { titleEl.style.borderColor = '#dc2626'; titleEl.focus(); return; } if (!desc) { descEl.style.borderColor = '#dc2626'; descEl.focus(); return; } submitBtn.disabled = true; statusEl.style.display = ''; statusEl.style.background = '#f1f5f9'; statusEl.style.color = '#64748b'; statusEl.textContent = t('about.report_bug_sending'); try { const res = await api('report_bug', null, 'POST', { type, title, description: desc, steps, user_agent: navigator.userAgent, url: location.href, version: _loadedVersion || '', lang: _currentLang || 'it', }); if (res.ok) { statusEl.style.background = '#dcfce7'; statusEl.style.color = '#15803d'; const issueRef = res.issue ? ` (#${res.issue})` : ''; statusEl.textContent = t('about.report_bug_sent') + issueRef; submitBtn.style.display = 'none'; setTimeout(() => closeModal(), 3500); } else { throw new Error(res.error || 'error'); } } catch(e) { statusEl.style.background = '#fee2e2'; statusEl.style.color = '#dc2626'; statusEl.textContent = t('about.report_bug_error'); submitBtn.disabled = false; } } 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; const ssTimeout = document.getElementById('setting-screensaver-timeout'); if (ssTimeout) ssTimeout.value = String(s.screensaver_timeout || 5); 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 => `${mpt.icon} ${mpt.label}` ).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); } // HA settings — init defaults on first load if (!s._ha_initialized) { s.ha_enabled = s.ha_enabled || false; s.ha_url = s.ha_url || ''; s.ha_tts_entity = s.ha_tts_entity || ''; s.ha_webhook_id = s.ha_webhook_id || ''; s.ha_webhook_events = s.ha_webhook_events || 'expiry,shopping_add,stock_update'; s.ha_notify_service = s.ha_notify_service || ''; s.ha_expiry_days = s.ha_expiry_days || 3; s._ha_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', 'price_enabled','price_country','price_currency','price_update_months', 'shopping_enabled','shopping_mode','shopping_smart_suggestions', 'shopping_forecast','shopping_auto_add_threshold', 'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events', 'ha_notify_service','ha_expiry_days']; // Note: gemini_key is never sent from server; settings_token_set is metadata only const settingsTokenRequired = !!(serverSettings.api_token_required || serverSettings.settings_token_set); const tokenHintEl = document.getElementById('settings-token-status-hint'); if (tokenHintEl) tokenHintEl.style.display = settingsTokenRequired ? 'block' : 'none'; if (settingsTokenRequired && typeof setApiToken === 'function') { const fieldTok = document.getElementById('setting-settings-token')?.value.trim(); if (fieldTok) setApiToken(fieldTok); } 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; // 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'; // Price settings (server merge) if (priceEnabledEl) { priceEnabledEl.checked = !!s.price_enabled; const pSub = document.getElementById('price-settings-sub'); if (pSub) pSub.style.display = s.price_enabled ? '' : 'none'; } if (priceCountryEl) priceCountryEl.value = s.price_country || 'Italia'; if (priceCurrencyEl) priceCurrencyEl.value = s.price_currency || 'EUR'; if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3; // Shopping settings (server merge) _applyShoppingSettingsUI(s); // HA settings (server merge) _applyHaSettingsUI(s); } } catch(e) { /* offline, use local */ } // Price settings const priceEnabledEl = document.getElementById('setting-price-enabled'); if (priceEnabledEl) { priceEnabledEl.checked = !!s.price_enabled; const priceSubEl = document.getElementById('price-settings-sub'); if (priceSubEl) priceSubEl.style.display = s.price_enabled ? '' : 'none'; priceEnabledEl.onchange = function() { const sub = document.getElementById('price-settings-sub'); if (sub) sub.style.display = this.checked ? '' : 'none'; }; } const priceCountryEl = document.getElementById('setting-price-country'); if (priceCountryEl) priceCountryEl.value = s.price_country || 'Italia'; const priceCurrencyEl = document.getElementById('setting-price-currency'); if (priceCurrencyEl) priceCurrencyEl.value = s.price_currency || 'EUR'; const priceMonthsEl = document.getElementById('setting-price-update-months'); if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3; // 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 || ''; // Backup settings pre-fill (populated fully when _loadBackupTab() is called) const bkRetEl = document.getElementById('setting-backup-retention-days'); if (bkRetEl && !bkRetEl.dataset.loaded) bkRetEl.value = s.backup_retention_days || 3; const gdriveEnUiEl = document.getElementById('setting-gdrive-enabled'); if (gdriveEnUiEl) gdriveEnUiEl.checked = !!s.gdrive_enabled; const gdriveFolderUiEl = document.getElementById('setting-gdrive-folder-id'); if (gdriveFolderUiEl && !gdriveFolderUiEl.dataset.loaded) gdriveFolderUiEl.value = s.gdrive_folder_id || ''; const gdriveRetUiEl = document.getElementById('setting-gdrive-retention-days'); if (gdriveRetUiEl && !gdriveRetUiEl.dataset.loaded) gdriveRetUiEl.value = s.gdrive_retention_days || 30; // Shopping settings _applyShoppingSettingsUI(s); // 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'; } // In kiosk mode: replace WebSocket scale config with native BLE reconfigure panel const isKiosk = typeof _kioskBridge !== 'undefined'; const scaleGwDl = document.getElementById('scale-gateway-download-section'); const scaleWsEl = document.getElementById('scale-websocket-section'); const scaleTestEl = document.getElementById('scale-test-section'); const scaleKiosk = document.getElementById('scale-kiosk-panel'); if (isKiosk) { if (scaleGwDl) scaleGwDl.style.display = 'none'; if (scaleWsEl) scaleWsEl.style.display = 'none'; if (scaleTestEl) scaleTestEl.style.display = 'none'; if (scaleKiosk) scaleKiosk.style.display = ''; // Auto-set URL to localhost gateway (always port 8765 in kiosk) if (scaleUrlUiEl && !scaleUrlUiEl.value) scaleUrlUiEl.value = 'ws://localhost:8765'; // Show kiosk self-update panel const updatePanel = document.getElementById('kiosk-update-panel'); if (updatePanel) updatePanel.style.display = ''; // Show kiosk native settings shortcut panel const nativePanel = document.getElementById('kiosk-native-settings-panel'); if (nativePanel) nativePanel.style.display = ''; } // Dark mode setting const dmEl = document.getElementById('setting-dark-mode'); if (dmEl) dmEl.value = s.dark_mode || 'auto'; // Zero-waste tips setting const zwEl = document.getElementById('setting-zerowaste-tips'); if (zwEl) zwEl.checked = s.zerowaste_tips_enabled === true; // Populate About section version _loadAboutSection(); } // ── Kiosk: trigger native BLE scale reconfiguration wizard ──────────── function _kioskReconfigureScale() { if (typeof _kioskBridge === 'undefined') return; if (typeof _kioskBridge.reconfigureScale === 'function') { try { _kioskBridge.reconfigureScale(); } catch(e) {} } else { // Kiosk APK is outdated — show update notice const notice = document.getElementById('kiosk-needs-update-notice'); if (notice) notice.style.display = ''; showToast(t('settings.kiosk_update_required'), 'warning'); } } // ── Kiosk: open native SettingsActivity (server URL, BLE, screensaver) ── function _openKioskNativeSettings() { if (typeof _kioskBridge === 'undefined') return; // Use try/catch directly: Android @JavascriptInterface methods are not always // detected as 'function' by typeof, so we just call and catch if unavailable. try { _kioskBridge.openNativeSettings(); } catch(e) { // Older APK without openNativeSettings bridge — inform user to update showToast(t('settings.kiosk.native_update_hint'), 'warning', 4000); } } // ── Kiosk: manual update check ──────────────────────────────────────── let _kioskPendingApkUrl = ''; /** Called by Kotlin with JSON: { has_update, current, latest, apk_url, error } */ window._kioskUpdateResult = function(result) { const btn = document.getElementById('btn-kiosk-check-update'); const status = document.getElementById('kiosk-update-status'); const installBtn = document.getElementById('btn-kiosk-install-update'); const verLabel = document.getElementById('kiosk-update-version-label'); if (!status) return; if (btn) { btn.disabled = false; btn.textContent = t('kiosk.check_btn'); } if (result.error && !result.has_update) { status.style.display = ''; status.style.background = 'rgba(239,68,68,0.1)'; status.style.border = '1px solid rgba(239,68,68,0.3)'; status.style.color = ''; status.innerHTML = `❌ ${t('error.prefix')}: ${result.error}`; return; } const current = result.current || '?'; const latest = result.latest || '?'; if (verLabel) verLabel.textContent = t('kiosk.version_installed').replace('{v}', current); if (result.has_update) { _kioskPendingApkUrl = result.apk_url || ''; status.style.display = ''; status.style.background = 'rgba(245,158,11,0.1)'; status.style.border = '1px solid rgba(245,158,11,0.35)'; status.style.color = ''; status.innerHTML = t('kiosk.update_available').replace('{latest}', latest).replace('{current}', current); if (installBtn) installBtn.style.display = ''; } else { _kioskPendingApkUrl = ''; status.style.display = ''; status.style.background = 'rgba(52,211,153,0.1)'; status.style.border = '1px solid rgba(52,211,153,0.3)'; status.style.color = ''; status.innerHTML = t('kiosk.up_to_date').replace('{v}', current); if (installBtn) installBtn.style.display = 'none'; } }; function _kioskCheckForUpdates() { if (typeof _kioskBridge === 'undefined' || typeof _kioskBridge.checkForUpdates !== 'function') { // Kiosk is present but old — trigger download via installUpdate which exists since v1.3 const status = document.getElementById('kiosk-update-status'); const installBtn = document.getElementById('btn-kiosk-install-update'); if (status) { status.style.display = ''; status.style.background = 'rgba(245,158,11,0.1)'; status.style.border = '1px solid rgba(245,158,11,0.35)'; status.innerHTML = t('kiosk.too_old'); } // Pre-set the pending URL and show the install button (installUpdate works in old APKs too) _kioskPendingApkUrl = 'https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk'; if (installBtn) installBtn.style.display = ''; return; } const btn = document.getElementById('btn-kiosk-check-update'); const status = document.getElementById('kiosk-update-status'); const installBtn = document.getElementById('btn-kiosk-install-update'); if (btn) { btn.disabled = true; btn.textContent = t('kiosk.checking'); } if (status) { status.style.display = 'none'; } if (installBtn) { installBtn.style.display = 'none'; } _kioskPendingApkUrl = ''; try { _kioskBridge.checkForUpdates(); } catch(e) { if (btn) { btn.disabled = false; btn.textContent = t('kiosk.check_btn'); } showToast('❌ ' + t('kiosk.error_check'), 'error'); } } function _kioskInstallUpdate() { if (!_kioskPendingApkUrl) return; if (typeof _kioskBridge === 'undefined') return; if (typeof _kioskBridge.installUpdate !== 'function') { // Old APK without installUpdate — show instructions const status = document.getElementById('kiosk-update-status'); if (status) { status.style.display = ''; status.style.background = 'rgba(239,68,68,0.1)'; status.style.border = '1px solid rgba(239,68,68,0.3)'; status.innerHTML = t('kiosk.manual_install') + `
https://github.com/dadaloop82/EverShelf/releases/download/kiosk-latest/evershelf-kiosk.apk `; } return; } const installBtn = document.getElementById('btn-kiosk-install-update'); if (installBtn) { installBtn.disabled = true; installBtn.textContent = t('kiosk.starting_download'); } try { _kioskBridge.installUpdate(_kioskPendingApkUrl); } catch(e) { if (installBtn) { installBtn.disabled = false; installBtn.textContent = t('kiosk.install_btn'); } showToast('❌ ' + t('kiosk.error_start_install'), 'error'); } } // ── 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; // Always mark header as kiosk-mode (idempotent). const appHeader = document.querySelector('.app-header'); if (appHeader) appHeader.classList.add('kiosk-mode'); // Permanently hide the native Android settings button. // Kiosk configuration is accessible ONLY through the web settings page (⚙️ below). try { _kioskBridge.setNativeSettingsVisible(false); } catch (_) {} 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;'; // If the Kotlin onPageFinished already injected #_kiosk_overlay (with ✕ and ↻), // just add the ⚙️ button if missing (do not duplicate the other buttons). const existing = document.getElementById('_kiosk_overlay'); if (existing) { if (!document.getElementById('_kiosk_settings_btn')) { const sBtn = document.createElement('button'); sBtn.id = '_kiosk_settings_btn'; sBtn.textContent = '⚙️'; sBtn.title = t('settings.title') || 'Impostazioni'; sBtn.style.cssText = btnStyle; sBtn.addEventListener('click', (e) => { e.stopPropagation(); showPage('settings'); }); existing.appendChild(sBtn); } 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;'; // Exit button const exitBtn = document.createElement('button'); exitBtn.id = '_kiosk_exit_btn'; exitBtn.textContent = '\u2715'; exitBtn.title = t('kiosk.exit_title'); 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 = t('kiosk.refresh_title'); refBtn.style.cssText = btnStyle.replace('font-size:15px', 'font-size:18px'); refBtn.addEventListener('click', (e) => { e.stopPropagation(); _kioskBridge.hardReload(); }); // Settings button — only web settings, native button is permanently hidden const settingsBtn = document.createElement('button'); settingsBtn.id = '_kiosk_settings_btn'; settingsBtn.textContent = '⚙️'; settingsBtn.title = t('settings.title') || 'Impostazioni'; settingsBtn.style.cssText = btnStyle; settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); showPage('settings'); }); wrap.appendChild(exitBtn); wrap.appendChild(refBtn); wrap.appendChild(settingsBtn); headerLeft.appendChild(wrap); } const _APPLIANCE_KEY_MAP = { 'forno': 'settings.appliances.oven', 'oven': 'settings.appliances.oven', 'backofen': 'settings.appliances.oven', 'microonde': 'settings.appliances.microwave', 'microwave': 'settings.appliances.microwave', 'mikrowelle': 'settings.appliances.microwave', 'friggitrice ad aria': 'settings.appliances.air_fryer', 'air fryer': 'settings.appliances.air_fryer', 'heißluftfritteuse': 'settings.appliances.air_fryer', 'macchina del pane': 'settings.appliances.bread_maker', 'macchina pane': 'settings.appliances.bread_maker', 'bread maker': 'settings.appliances.bread_maker', 'bread machine': 'settings.appliances.bread_maker', 'brotbackmaschine': 'settings.appliances.bread_maker', 'brotbackautomat': 'settings.appliances.bread_maker', 'bimby/moulinex cookeo': 'settings.appliances.bimby', 'moulinex cookeo': 'settings.appliances.bimby', 'bimby/cookeo': 'settings.appliances.bimby', 'bimby': 'settings.appliances.bimby', 'thermomix': 'settings.appliances.bimby', 'thermomix/cookeo': 'settings.appliances.bimby', 'planetaria': 'settings.appliances.mixer', 'stand mixer': 'settings.appliances.mixer', 'küchenmaschine': 'settings.appliances.mixer', 'vaporiera': 'settings.appliances.steamer', 'steamer': 'settings.appliances.steamer', 'dampfgarer': 'settings.appliances.steamer', 'pentola a pressione': 'settings.appliances.pressure_cooker', 'pentola pressione': 'settings.appliances.pressure_cooker', 'pressure cooker': 'settings.appliances.pressure_cooker', 'schnellkochtopf': 'settings.appliances.pressure_cooker', 'tostapane': 'settings.appliances.toaster', 'toaster': 'settings.appliances.toaster', 'frullatore/mixer': 'settings.appliances.blender', 'frullatore': 'settings.appliances.blender', 'blender': 'settings.appliances.blender', 'mixer': 'settings.appliances.blender', }; function _applianceDisplayName(name) { const key = _APPLIANCE_KEY_MAP[name.toLowerCase().trim()]; if (!key) return name; // Strip leading emoji/symbols from the translated button label (e.g. "🔥 Oven" → "Oven") return t(key).replace(/^[^\p{L}]+/u, '').trim() || name; } function renderAppliances(appliances) { const container = document.getElementById('appliances-list'); if (!appliances || appliances.length === 0) { container.innerHTML = `

${t('appliances.empty')}

`; return; } container.innerHTML = appliances.map((a, i) => `
🔌 ${escapeHtml(_applianceDisplayName(a))}
`).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(t('toast.appliance_added'), 'success'); } function removeAppliance(idx) { const s = getSettings(); if (!s.appliances) return; s.appliances.splice(idx, 1); saveSettingsToStorage(s); renderAppliances(s.appliances); } function _applyShoppingSettingsUI(s) { const enabledEl = document.getElementById('setting-shopping-enabled'); if (enabledEl) enabledEl.checked = s.shopping_enabled !== false; const mode = s.shopping_mode || 'internal'; document.querySelectorAll('input[name="shopping-mode"]').forEach(r => { r.checked = (r.value === mode); }); const bringSection = document.getElementById('bring-subsection'); if (bringSection) bringSection.style.display = mode === 'bring' ? '' : 'none'; const suggestEl = document.getElementById('setting-shopping-smart-suggestions'); if (suggestEl) suggestEl.checked = s.shopping_smart_suggestions !== false; const forecastEl = document.getElementById('setting-shopping-forecast'); if (forecastEl) forecastEl.checked = s.shopping_forecast !== false; const autoAddEl = document.getElementById('setting-shopping-auto-add'); if (autoAddEl) autoAddEl.value = s.shopping_auto_add_threshold || 0; } function onShoppingEnabledChange() { const s = getSettings(); s.shopping_enabled = document.getElementById('setting-shopping-enabled').checked; saveSettingsToStorage(s); _saveSettingToServer({ shopping_enabled: s.shopping_enabled }); } function onShoppingModeChange(value) { const bringSection = document.getElementById('bring-subsection'); if (bringSection) bringSection.style.display = value === 'bring' ? '' : 'none'; const s = getSettings(); s.shopping_mode = value; saveSettingsToStorage(s); _saveSettingToServer({ shopping_mode: value }); } async function saveSettings() { const s = getSettings(); // Only update gemini_key if user actually typed something; preserve existing key otherwise const _newGeminiKey = document.getElementById('setting-gemini-key').value.trim(); if (_newGeminiKey) s.gemini_key = _newGeminiKey; 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; const ssTimeoutEl = document.getElementById('setting-screensaver-timeout'); if (ssTimeoutEl) s.screensaver_timeout = parseInt(ssTimeoutEl.value, 10) || 5; // Dark mode const dmSaveEl = document.getElementById('setting-dark-mode'); if (dmSaveEl) { s.dark_mode = dmSaveEl.value; _applyTheme(); } // Zero-waste tips const zwSaveEl = document.getElementById('setting-zerowaste-tips'); if (zwSaveEl) s.zerowaste_tips_enabled = zwSaveEl.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(); // Price settings const priceEnabledSaveEl = document.getElementById('setting-price-enabled'); if (priceEnabledSaveEl) s.price_enabled = priceEnabledSaveEl.checked; const priceCountrySaveEl = document.getElementById('setting-price-country'); if (priceCountrySaveEl) s.price_country = priceCountrySaveEl.value; const priceCurrencySaveEl = document.getElementById('setting-price-currency'); if (priceCurrencySaveEl) s.price_currency = priceCurrencySaveEl.value; const priceMonthsSaveEl = document.getElementById('setting-price-update-months'); if (priceMonthsSaveEl) s.price_update_months = parseInt(priceMonthsSaveEl.value, 10) || 3; // Backup settings const backupEnabledEl = document.getElementById('setting-backup-enabled'); if (backupEnabledEl) s.backup_enabled = backupEnabledEl.checked; const backupRetentionEl = document.getElementById('setting-backup-retention-days'); if (backupRetentionEl) s.backup_retention_days = parseInt(backupRetentionEl.value, 10) || 3; const gdriveEnabledEl = document.getElementById('setting-gdrive-enabled'); if (gdriveEnabledEl) s.gdrive_enabled = gdriveEnabledEl.checked; const gdriveFolderEl = document.getElementById('setting-gdrive-folder-id'); if (gdriveFolderEl) s.gdrive_folder_id = gdriveFolderEl.value.trim(); const gdriveRetentionEl = document.getElementById('setting-gdrive-retention-days'); if (gdriveRetentionEl) s.gdrive_retention_days = parseInt(gdriveRetentionEl.value, 10) || 30; // Shopping settings const shoppingEnabledEl = document.getElementById('setting-shopping-enabled'); if (shoppingEnabledEl) s.shopping_enabled = shoppingEnabledEl.checked; const shoppingModeEl = document.querySelector('input[name="shopping-mode"]:checked'); if (shoppingModeEl) s.shopping_mode = shoppingModeEl.value; const shoppingSuggestEl = document.getElementById('setting-shopping-smart-suggestions'); if (shoppingSuggestEl) s.shopping_smart_suggestions = shoppingSuggestEl.checked; const shoppingForecastEl = document.getElementById('setting-shopping-forecast'); if (shoppingForecastEl) s.shopping_forecast = shoppingForecastEl.checked; const shoppingAutoAddEl = document.getElementById('setting-shopping-auto-add'); if (shoppingAutoAddEl) s.shopping_auto_add_threshold = parseInt(shoppingAutoAddEl.value, 10) || 0; // OAuth fields const gdriveClientIdEl = document.getElementById('setting-gdrive-client-id'); if (gdriveClientIdEl && gdriveClientIdEl.value.trim()) s.gdrive_client_id = gdriveClientIdEl.value.trim(); const gdriveClientSecretEl = document.getElementById('setting-gdrive-client-secret'); if (gdriveClientSecretEl && gdriveClientSecretEl.value.trim()) s.gdrive_client_secret = gdriveClientSecretEl.value.trim(); saveSettingsToStorage(s); // Save ALL settings to server .env try { const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : ''); if (settingsToken && typeof setApiToken === 'function') setApiToken(settingsToken); const tokenHeader = settingsToken ? { 'X-API-Token': settingsToken } : (typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}); 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, screensaver_timeout: s.screensaver_timeout || 5, zerowaste_tips_enabled: s.zerowaste_tips_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, tts_engine: s.tts_engine || '', tts_rate: s.tts_rate || 1, tts_pitch: s.tts_pitch || 1, tts_auth_header_name: s.tts_auth_header_name || '', tts_auth_header_value: s.tts_auth_header_value || '', tts_extra_fields: s.tts_extra_fields || '', price_enabled: s.price_enabled, price_country: s.price_country, price_currency: s.price_currency, price_update_months: s.price_update_months, recipe_retention_days: s.recipe_retention_days || 7, transaction_retention_days: s.transaction_retention_days || 90, vacuum_expiry_extension_days: s.vacuum_expiry_extension_days || 30, backup_enabled: s.backup_enabled !== false, backup_retention_days: s.backup_retention_days || 3, gdrive_enabled: !!s.gdrive_enabled, gdrive_folder_id: s.gdrive_folder_id || '', gdrive_retention_days: s.gdrive_retention_days || 30, ...(s.gdrive_client_id ? { gdrive_client_id: s.gdrive_client_id } : {}), ...(s.gdrive_client_secret ? { gdrive_client_secret: s.gdrive_client_secret } : {}), shopping_enabled: s.shopping_enabled !== false, shopping_mode: s.shopping_mode || 'internal', shopping_smart_suggestions: s.shopping_smart_suggestions !== false, shopping_forecast: s.shopping_forecast !== false, shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0, dark_mode: s.dark_mode || 'auto', barcode_ai_fallback: !!s.barcode_ai_fallback, // Home Assistant ha_enabled: !!s.ha_enabled, ha_url: s.ha_url || '', ...(s.ha_token ? { ha_token: s.ha_token } : {}), ha_tts_entity: s.ha_tts_entity || '', ha_webhook_id: s.ha_webhook_id || '', ha_webhook_events: s.ha_webhook_events || '', ha_notify_service: s.ha_notify_service || '', ha_expiry_days: s.ha_expiry_days || 3, }, 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); } // Re-sync _geminiAvailable after save (key may have been set/confirmed on server) try { const refreshed = await api('get_settings'); if (refreshed && refreshed.gemini_key_set !== undefined) { _geminiAvailable = !!(refreshed.gemini_key_set); _updateGeminiButtonState(); } } catch(e) {} // Persist meal_plan and tts_voice to SQLite for cross-device sync try { const appData = {}; if (s.meal_plan) appData.meal_plan = s.meal_plan; if (s.tts_voice) appData.tts_voice = s.tts_voice; if (Object.keys(appData).length) await api('app_settings_save', {}, 'POST', { settings: appData }); } catch(e) {} // Re-init screensaver watcher in case it was just enabled initInactivityWatcher(); } function switchSettingsTab(btn, tabId) { // Stop info-tab auto-refresh when leaving that tab if (tabId !== 'tab-info' && _infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; } 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 shopping write operations are no-ops if (_demoMode) { const BRING_WRITE_ACTIONS = ['bring_add', 'bring_remove', 'bring_migrate_names', 'bring_set_spec', 'shopping_add', 'shopping_remove']; if (BRING_WRITE_ACTIONS.includes(action)) { return { success: true, added: 0, removed: 0, skipped: 0, _demo: true }; } // shopping_list / bring_list return the in-memory demo list if (action === 'shopping_list' || action === 'bring_list') { return { success: true, purchase: shoppingItems, listUUID: 'demo-list', _demo: true }; } } // In offline mode, serve from cache / queue writes if (_offlineMode) { return _handleOfflineApi(action, params, body); } let url = `${API_BASE}?action=${action}`; if (method === 'GET') { Object.entries(params).forEach(([k, v]) => { url += `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`; }); } const opts = { method, cache: 'no-store' }; const authHdrs = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}; if (body) { opts.headers = { 'Content-Type': 'application/json', 'X-EverShelf-Request': '1', ...authHdrs, ...extraHeaders }; opts.body = JSON.stringify(body); } else { opts.headers = { ...authHdrs, ...extraHeaders }; } let res; try { res = await fetch(url, opts); // Server responded → reset failure counter and hide overlay if it was showing if (_networkDown) _hideNetworkOverlay(true); _networkFailCount = 0; } catch (fetchErr) { // Network-level failure (no route to host, Wi-Fi down, etc.) _networkFailCount++; if (_networkFailCount >= _NETWORK_FAIL_THRESHOLD) { _showNetworkOverlay(); } throw fetchErr; } if (!res.ok) { remoteLog('API_ERROR', `${action} HTTP ${res.status}`); if (res.status === 401) { window._apiTokenRequired = true; if (typeof _promptApiTokenIfNeeded === 'function') _promptApiTokenIfNeeded(); } // 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(); // Keep local caches fresh for offline use (only ever written when server responds successfully) if (action === 'inventory_list' && data && Array.isArray(data.inventory)) { _offlineCacheSet(data.inventory); } if (action === 'get_settings' && data && data.success !== false) { window._apiTokenRequired = !!data.api_token_required; if (data.api_token_required && typeof _promptApiTokenIfNeeded === 'function') { _promptApiTokenIfNeeded(); } _offlineCacheSetSettings(data); } if (action === 'products_list' && data && Array.isArray(data.products)) { _offlineProductsSet(data.products); } 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; let _pageHistory = [{ pageId: 'dashboard', param: null }]; function goBack(fallbackPage = 'dashboard') { if (_pageHistory.length > 1) { // Drop current page and navigate to the previous entry without re-adding history. _pageHistory.pop(); const prev = _pageHistory[_pageHistory.length - 1] || { pageId: fallbackPage, param: null }; showPage(prev.pageId, prev.param, { skipHistory: true }); return; } showPage(fallbackPage, null, { skipHistory: true }); } // Refresh current page data without full navigation function refreshCurrentPage() { switch(_currentPageId) { case 'dashboard': loadDashboard(); break; case 'inventory': loadInventory(); break; case 'shopping': loadShoppingList._bgCall = true; loadShoppingList(); break; case 'products': loadAllProducts(); break; case 'recipe': loadRecipeArchive(); loadRecipeLibrary(); break; case 'log': loadLog(); break; // scan/ai/settings/chat: nessun dato live da ricaricare } } function showPage(pageId, param = null, options = {}) { const skipHistory = !!options.skipHistory; if (!skipHistory) { const last = _pageHistory[_pageHistory.length - 1]; const sameAsLast = !!last && last.pageId === pageId && (last.param ?? null) === (param ?? null); if (!sameAsLast) { _pageHistory.push({ pageId, param }); if (_pageHistory.length > 80) _pageHistory.shift(); } } _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': loadDashboard(); break; case 'inventory': if (param !== null) { currentLocation = param; filterLocation(param); } loadInventory(); break; case 'scan': _dismissFamilySiblingPrompt(); _resetAiFallbackForNewScan(); initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); _applySpesaScanUI(); // 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': _shoppingInventoryCache = null; // invalidate so hints use fresh data loadShoppingList(); break; case 'recipe': loadRecipeArchive(); loadRecipeLibrary(); break; case 'log': loadLog(); break; case 'ai': initAICamera(); break; case 'settings': loadSettingsUI(); break; case 'config': _loadConfigPage(); 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); } // Auto-refresh shopping list every 45s while on shopping page so all clients stay in sync if (_shoppingPollTimer) { clearInterval(_shoppingPollTimer); _shoppingPollTimer = null; } if (pageId === 'shopping') { _shoppingPollTimer = setInterval(() => { loadShoppingList._bgCall = true; loadShoppingList(); loadSmartShopping().then(() => { _syncOnBringFlags(); renderSmartShopping(); updateShoppingTabCounts(); }); }, 45 * 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", "30–40% 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 `
${label}
`; } const cls = rate <= 8 ? 'good' : rate <= 20 ? 'ok' : 'bad'; const barPct = Math.max(4, Math.round((rate / Math.max(maxRate, 5)) * 100)); return `
${label} ${rate}%
`; } /** 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; } // Show only if the alternation phase allows it (or before alternation starts) section.style.display = (!_insightPhase || _insightPhase === 'waste') ? 'block' : 'none'; 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(` 📊 ${myRate}%${t('antiwaste.badge_rate')} `); if (wasted30 > 0) allBadges.push(` 🗑️ ${wasted30}${t('antiwaste.badge_wasted')} `); if (savedMoney > 0) allBadges.push(` 💰 ${bm.currency}${savedMoney}/m${t('antiwaste.badge_saved_money')} `); if (savedCO2 > 0) allBadges.push(` 🌍 −${savedCO2} kgCO₂ `); if (diffPct > 0) allBadges.push(` −${diffPct}%${t('antiwaste.badge_better')} `); // 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 = `

${t('antiwaste.title')}

${grade}
▮ ${youLabel} ${myRate}% ${country} ${avgRate}%

${statusMsg}  ·  ${annualInfo}

${allBadges.length > 0 ? `
${initBadges}
` : ''}
💡 ${facts[factIdx]}
${(_awLiveFacts && _awLiveFacts.source) || t('antiwaste.source')}
`; // 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); } } // ===== NUTRITION ANALYSIS SECTION ===== // Alternates with waste-chart-section every hour (randomised offset) // Colour palette for pie slices (matches category colours) const _NUTR_COLORS = { 'frutta': '#4ade80', 'verdura': '#22d3ee', 'carne': '#f87171', 'pesce': '#60a5fa', 'latticini': '#fbbf24', 'pasta': '#a78bfa', 'pane': '#fb923c', 'cereali': '#f472b6', 'bevande': '#34d399', 'condimenti':'#94a3b8', 'surgelati': '#818cf8', 'conserve': '#e879f9', 'snack': '#fcd34d', 'altro': '#64748b', }; let _nutriData = null; // cached result from last inventory fetch let _insightFlipTimer = null; // setInterval handle for waste/nutrition alternation /** * Compute nutrition-related metrics from the current inventory array. * Returns null if not enough data. */ function _buildNutritionData(inventory) { if (!inventory || inventory.length === 0) return null; // Category distribution (product count) const catCounts = {}; for (const item of inventory) { const cat = mapToLocalCategory(item.category || '', item.name || ''); catCounts[cat] = (catCounts[cat] || 0) + 1; } const total = Object.values(catCounts).reduce((s, v) => s + v, 0); // Sorted slices for pie const slices = Object.entries(catCounts) .sort((a, b) => b[1] - a[1]) .map(([cat, count]) => ({ cat, count, pct: Math.round(count / total * 100), color: _NUTR_COLORS[cat] || '#64748b', icon: CATEGORY_ICONS[cat] || '📦', })); // Health score 0-100 based on category mix // + points for fruit/veg/fish; - for snacks/sweets const healthyCats = ['frutta','verdura','pesce','carne']; const unhealthyCats = ['snack','bevande']; const healthyCount = healthyCats.reduce((s, c) => s + (catCounts[c] || 0), 0); const unhealthyCount= unhealthyCats.reduce((s, c) => s + (catCounts[c] || 0), 0); const healthScore = Math.min(100, Math.max(0, Math.round(50 + (healthyCount / Math.max(total, 1)) * 50 - (unhealthyCount / Math.max(total, 1)) * 30) )); // Variety score: number of distinct categories / max(16) const varietyScore = Math.min(100, Math.round(Object.keys(catCounts).length / 16 * 100)); // Freshness score: % products with expiry date set const withExpiry = inventory.filter(i => i.expiry_date).length; const freshnessScore = Math.round(withExpiry / Math.max(total, 1) * 100); // Balance: fraction of fresh (frigo+freezer) vs shelf-stable (dispensa) const fresh = inventory.filter(i => i.location === 'frigo' || i.location === 'freezer').length; const fresh_pct = Math.round(fresh / Math.max(total, 1) * 100); return { slices, total, healthScore, varietyScore, freshnessScore, fresh_pct }; } /** * Render the nutrition analysis card into #nutrition-section. */ function _renderNutritionSection(inventory) { const section = document.getElementById('nutrition-section'); if (!section) return; const data = _buildNutritionData(inventory); if (!data) { section.style.display = 'none'; return; } _nutriData = data; const { slices, total, healthScore, varietyScore, freshnessScore, fresh_pct } = data; const top5 = slices.slice(0, 5); // Build conic-gradient for pie let deg = 0; const stops = top5.map(s => { const end = deg + s.pct * 3.6; const stop = `${s.color} ${deg.toFixed(1)}deg ${end.toFixed(1)}deg`; deg = end; return stop; }); if (deg < 360) stops.push(`#334155 ${deg.toFixed(1)}deg 360deg`); const gradient = `conic-gradient(from 0deg, ${stops.join(', ')})`; // Score colour const scoreColor = healthScore >= 70 ? '#4ade80' : healthScore >= 45 ? '#fbbf24' : '#f87171'; const scoreLabel = healthScore >= 70 ? t('nutrition.score_excellent') : healthScore >= 45 ? t('nutrition.score_good') : t('nutrition.score_improve'); section.innerHTML = `

${t('nutrition.title')}

${scoreLabel}
${total} ${t('nutrition.products_count')}
${top5.map(s => `
${s.icon} ${t('categories.' + s.cat) || s.cat} ${s.pct}%
`).join('')}
${_nutrScoreBar(t('nutrition.label_health'), healthScore, '#4ade80')} ${_nutrScoreBar(t('nutrition.label_variety'), varietyScore, '#60a5fa')} ${_nutrScoreBar(t('nutrition.label_fresh'), fresh_pct, '#22d3ee')}
${t('nutrition.source').replace('{n}', total)}
`; // Trigger pie animation after render requestAnimationFrame(() => { const pie = document.getElementById('nutr-pie'); if (pie) setTimeout(() => pie.classList.add('nutr-pie-ready'), 60); }); } function _nutrScoreBar(label, val, color) { return `
${label}
${val}%
`; } // ===== MONTHLY STATS SECTION ===== // Third panel in the insight rotation (waste → nutrition → monthly → waste …) function _renderMonthlyStatsSection(data) { const section = document.getElementById('monthly-stats-section'); if (!section) return; if (!data || !data.success || data.items_consumed === 0) { section.innerHTML = ''; section.style.display = 'none'; return; } // Month label from 'YYYY-MM' → formatted locale string const [yr, mo] = data.month.split('-').map(Number); const localeMap = { de: 'de-DE', fr: 'fr-FR', es: 'es-ES', en: 'en-GB', it: 'it-IT' }; const locale = localeMap[_currentLang] || 'it-IT'; const monthLabel = new Date(yr, mo - 1, 1).toLocaleDateString(locale, { month: 'long', year: 'numeric' }); const prevLabel = new Date(yr, mo - 2, 1).toLocaleDateString(locale, { month: 'long' }); // Trend vs previous month let trendHTML = ''; const prev = data.items_consumed_prev; const curr = data.items_consumed; if (prev > 0) { const diff = Math.round((curr - prev) / prev * 100); if (diff < -2) { trendHTML = `↓ ${t('stats_monthly.trend_down').replace('{pct}', Math.abs(diff)).replace('{prev}', prevLabel)}`; } else if (diff > 2) { trendHTML = `↑ ${t('stats_monthly.trend_up').replace('{pct}', diff).replace('{prev}', prevLabel)}`; } else { trendHTML = `→ ${t('stats_monthly.trend_same')}`; } } // Top category bars const top = (data.top_categories || []).slice(0, 4); const maxCnt = top.length ? Math.max(...top.map(c => c.count)) : 1; const catBars = top.map(c => { const color = _NUTR_COLORS[c.cat] || '#64748b'; const barPct = Math.round(c.count / maxCnt * 100); // t() returns the key itself when not found — guard against it const catKey = 'categories.' + c.cat; const label = t(catKey) !== catKey ? t(catKey) : c.cat.replace(/-/g, ' '); return `
${escapeHtml(label)}
${c.count}
`; }).join(''); // Badges const badges = []; if (data.items_added > 0) badges.push(`📦${data.items_added}${t('stats_monthly.added')}`); if (data.items_wasted > 0) { let wastedBadgeText = `${data.items_wasted}${t('stats_monthly.wasted')}`; if (data.wasted_value_eur > 0) { const sym = getSettings().price_currency === 'USD' ? '$' : (getSettings().price_currency === 'GBP' ? '£' : '€'); wastedBadgeText = `${data.items_wasted}${t('stats_monthly.wasted')} · ${sym}${data.wasted_value_eur.toFixed(2)}`; } badges.push(`🗑️${wastedBadgeText}`); } if (data.top_products?.length > 0) badges.push(`${escapeHtml(data.top_products[0].name)}${t('stats_monthly.top_used')}`); section.innerHTML = `

${t('stats_monthly.title')}

${monthLabel}
${curr}
${t('stats_monthly.consumed')}
${trendHTML}
${top.length > 0 ? `
${t('stats_monthly.top_cats')}
${catBars}
` : ''} ${badges.length > 0 ? `
${badges.join('')}
` : ''}
${t('stats_monthly.source')}
`; // Show only if it's the active phase (mirrors _applyInsightPhase logic) section.style.display = (_insightPhase === 'monthly') ? 'block' : 'none'; } // ===== MACROS SECTION (#118) ===== /** * Render the macronutrient breakdown panel into #macros-section. */ function _renderMacrosSection(data) { const section = document.getElementById('macros-section'); if (!section) return; if (!data || !data.success || data.total_items === 0) { section.innerHTML = ''; section.style.display = 'none'; return; } const { totals, ratios, total_items } = data; const macros = [ { key: 'carbohydrates', label: t('nutrition.macros_carbs'), color: '#a78bfa', value: totals.carbohydrates, unit: 'g', pct: ratios.carbohydrates }, { key: 'fat', label: t('nutrition.macros_fat'), color: '#fbbf24', value: totals.fat, unit: 'g', pct: ratios.fat }, { key: 'proteins', label: t('nutrition.macros_proteins'), color: '#4ade80', value: totals.proteins, unit: 'g', pct: ratios.proteins }, { key: 'fiber', label: t('nutrition.macros_fiber'), color: '#34d399', value: totals.fiber, unit: 'g', pct: null }, ]; const bars = macros.map(m => { const barPct = m.pct !== null ? m.pct : Math.min(100, Math.round((m.value / Math.max(totals.carbohydrates + totals.fat + totals.proteins, 1)) * 100)); return `
${m.label}
${m.value.toLocaleString(_currentLang === 'de' ? 'de-DE' : 'it-IT')}${m.unit}${m.pct !== null ? ` (${m.pct}%)` : ''}
`; }).join(''); section.innerHTML = `

${t('nutrition.macros_title')}

${totals.energy_kcal.toLocaleString()} kcal
${bars}
${t('nutrition.macros_source').replace('{n}', total_items)}
`; section.style.display = (_insightPhase === 'macros') ? 'block' : 'none'; } /** * Start the waste ↔ nutrition ↔ monthly stats alternation on the dashboard. */ let _insightPhase = null; // 'waste' | 'nutrition' | 'monthly' | 'macros' const _INSIGHT_PHASES = ['waste', 'nutrition', 'monthly', 'macros']; function _startInsightAlternation() { clearInterval(_insightFlipTimer); // Pick initial panel cycling through 3 phases based on current 60-second slot const idx = Math.floor(Date.now() / 60_000) % _INSIGHT_PHASES.length; _insightPhase = _INSIGHT_PHASES[idx]; _applyInsightPhase(); // Advance every 60 seconds (1 minute per panel) _insightFlipTimer = setInterval(() => { _insightPhase = _INSIGHT_PHASES[(_INSIGHT_PHASES.indexOf(_insightPhase) + 1) % _INSIGHT_PHASES.length]; _applyInsightPhase(); }, 60_000); } function _applyInsightPhase() { const wasteEl = document.getElementById('waste-chart-section'); const nutrEl = document.getElementById('nutrition-section'); const monthlyEl = document.getElementById('monthly-stats-section'); const macrosEl = document.getElementById('macros-section'); if (!wasteEl || !nutrEl) return; // Map of which panels actually have rendered content const hasContent = { 'waste': wasteEl.innerHTML.trim() !== '', 'nutrition': nutrEl.innerHTML.trim() !== '', 'monthly': !!monthlyEl && monthlyEl.innerHTML.trim() !== '', 'macros': !!macrosEl && macrosEl.innerHTML.trim() !== '', }; // If the intended phase has no content, advance to the next one that does let phase = _insightPhase; for (let i = 0; i < _INSIGHT_PHASES.length; i++) { if (hasContent[phase]) break; phase = _INSIGHT_PHASES[(_INSIGHT_PHASES.indexOf(phase) + 1) % _INSIGHT_PHASES.length]; } const showWaste = phase === 'waste'; const showNutr = phase === 'nutrition'; const showMonthly = phase === 'monthly'; const showMacros = phase === 'macros'; // Fade-swap all four panels const els = [wasteEl, nutrEl, ...(monthlyEl ? [monthlyEl] : []), ...(macrosEl ? [macrosEl] : [])]; els.forEach(el => { el.style.opacity = '0'; el.style.transition = 'opacity .6s'; }); setTimeout(() => { wasteEl.style.display = showWaste ? 'block' : 'none'; nutrEl.style.display = showNutr ? 'block' : 'none'; if (monthlyEl) monthlyEl.style.display = showMonthly ? 'block' : 'none'; if (macrosEl) macrosEl.style.display = showMacros ? 'block' : 'none'; requestAnimationFrame(() => { els.forEach(el => { el.style.opacity = '1'; }); if (showNutr) { nutrEl.querySelectorAll('.nutr-score-fill').forEach(bar => { bar.style.width = (bar.dataset.target || 0) + '%'; }); } if (showMonthly && monthlyEl) { monthlyEl.querySelectorAll('.ms-cat-bar').forEach(bar => { bar.style.transition = 'width 0.6s ease'; bar.style.width = (bar.dataset.target || 0) + '%'; }); } if (showMacros && macrosEl) { macrosEl.querySelectorAll('.macro-bar-fill').forEach(bar => { bar.style.transition = 'width 0.6s ease'; bar.style.width = (bar.dataset.target || 0) + '%'; }); } }); }, 620); } // ===== DASHBOARD ===== function renderDashboardLocationCards(counts) { const statsContainer = document.getElementById('dashboard-stats'); if (!statsContainer) return; const keys = Object.keys(LOCATIONS); const cardsHtml = keys.map(key => { const info = LOCATIONS[key]; const count = counts[key] !== undefined ? counts[key] : '-'; return `
${info.icon} ${count} ${escapeHtml(info.label)}
`; }).join(''); // Remove previously injected location cards (keep the static "Courses" card) statsContainer.querySelectorAll('.stat-card-location').forEach(el => el.remove()); // Insert before the Courses card (first remaining child) const shoppingCard = statsContainer.querySelector('.stat-card'); if (shoppingCard) { shoppingCard.insertAdjacentHTML('beforebegin', cardsHtml); } else { statsContainer.insertAdjacentHTML('afterbegin', cardsHtml); } } async function loadDashboard() { // Render one card per known location, with shimmer while loading renderDashboardLocationCards({}); try { const [summaryData, statsData] = await Promise.all([ api('inventory_summary'), api('stats') ]); // Update stat cards const summary = summaryData.summary || []; let total = 0; const counts = {}; Object.keys(LOCATIONS).forEach(loc => { const s = summary.find(x => x.location === loc); counts[loc] = s ? s.product_count : 0; total += counts[loc]; }); renderDashboardLocationCards(counts); // Load shopping list count from Bring! loadShoppingCount(); // Show last known total instantly, then refresh from server _loadCanonicalTotalFromSession(); _applyShoppingTotalDisplay(); // 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 `
${escapeHtml(item.name)} ${item.brand ? `${escapeHtml(item.brand)}` : ''}
📦 ${qtyDisplay} ${badgeText}
`; }).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 `
${locIcon ? locIcon + ' ' : ''}${escapeHtml(item.name)} ${item.brand ? `${escapeHtml(item.brand)}` : ''} 📦 ${qtyDisplayExp}
${daysText} ${safety.icon} ${safety.label}
`; }).join(''); } else { expiredSection.style.display = 'none'; } // Banner alerts (suspicious quantities + consumption predictions) loadBannerAlerts(); // Anti-waste section + Nutrition section + Monthly stats: load in parallel const [, invForNutr, monthlyData, macroData] = await Promise.all([ _awLoadFacts(), api('inventory_list').then(d => d.inventory || []).catch(() => []), api('monthly_stats').catch(() => null), api('macro_stats').catch(() => null), ]); _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(); // Nutrition section — built from the full inventory list _renderNutritionSection(invForNutr); // Monthly stats panel _renderMonthlyStatsSection(monthlyData); // Macronutrient panel (#118) _renderMacrosSection(macroData); _startInsightAlternation(); // 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': t('units.pz') }; let qtyText = ''; if (item.unit === 'conf') { const pkgUnit = item.package_unit; const pkgLabel = (pkgUnit && pkgUnit !== '') ? (unitLabels[pkgUnit] || pkgUnit) : ''; const wholeConf = Math.floor(qty + 0.001); const frac = Math.round((qty - wholeConf) * 1000) / 1000; const remainderAmt = pkgSize > 0 ? frac * pkgSize : 0; // Only show remainder if it rounds to at least 1 unit const remainderText = remainderAmt >= 0.5 ? formatSubRemainder(remainderAmt, pkgUnit) : ''; if (wholeConf > 0 && remainderText) { qtyText = `${wholeConf} ${t('units.conf') || 'conf'}${pkgLabel ? ` (${t('units.from') || 'da'} ${pkgSize}${pkgLabel})` : ''} + ${remainderText}`; } else if (wholeConf > 0) { qtyText = `${wholeConf} ${t('units.conf') || 'conf'}${pkgLabel ? ` (${t('units.from') || 'da'} ${pkgSize}${pkgLabel})` : ''}`; } else if (remainderText) { qtyText = remainderAmt >= 1 ? remainderText : t('inventory.qty_trace') || '< 1' + (pkgLabel || ''); } else { qtyText = `${Math.round(qty * 10) / 10} ${t('units.conf') || 'conf'}`; } } else { const unitLabel = unitLabels[item.unit] || item.unit || ''; if (!pkgSize || pkgSize <= 0) { // No package size — just show raw quantity qtyText = `${qty}${unitLabel}`; } else { const wholePackages = Math.floor(qty / pkgSize + 0.001); const remainder = Math.round((qty - wholePackages * pkgSize) * 100) / 100; if (wholePackages > 0 && remainder >= 1) { qtyText = `${wholePackages} × ${pkgSize}${unitLabel} + ${Math.round(remainder)}${unitLabel} ${t('inventory.qty_remainder_suffix')}`; } else if (remainder >= 1) { 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) { // Only show the red ⛔ badge for items that are genuinely dangerous. // For conserve/condiments classified as safe, use a gentler amber badge. const spoiledSafety = getExpiredSafety(item, Math.abs(item.days_to_expiry ?? 1)); if (spoiledSafety.level === 'ok') { expiryClass = 'opened-expiry-soon'; expiryText = '\u26A0\uFE0F ' + t('expiry.badge_check_soon'); } else { 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 = `${expiryText}${vacuumNote}`; } return `
${escapeHtml(item.name)} ${item.brand ? `${escapeHtml(item.brand)}` : ''}
${locInfo.icon} ${locInfo.label} ${qtyText} ${expiryBadge}
`; }).join('') + (extra > 0 ? `
${t('dashboard.more_opened').replace('{n}', extra)}
` : ''); } 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 = t('chat.quick_recipe_prompt') || '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' || unit === 'pz') && packageUnit) ? packageUnit : unit; const th = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz']; return n > th.max; } function getReviewConfirmed() { return _reviewConfirmedCache || {}; } let _reviewConfirmedCache = {}; // ===== SERVER-SYNCED APP DATA CACHES ===== // Loaded at startup from app_settings (SQLite). Reads are synchronous (from cache). // Writes update cache + fire-and-forget to server via app_settings_save. let _shoppingTagsCache = {}; let _pinnedBringCache = {}; let _prefUseLocCache = {}; let _prefMoveLocCache = {}; let _autoAddedBringCache = {}; let _bringBlocklistCache = {}; let _noExpiryDismissedCache = {}; let _familySiblingConfirmedCache = {}; let _scanHistoryCache = []; function _saveToServer(key, value) { api('app_settings_save', {}, 'POST', { settings: { [key]: value } }).catch(() => {}); } function setReviewConfirmed(inventoryId) { const c = getReviewConfirmed(); c[inventoryId] = Date.now(); _reviewConfirmedCache = c; api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {}); } /** Return map of product IDs the user has marked as "no expiry needed". */ function _getNoExpiryDismissed() { return _noExpiryDismissedCache || {}; } /** Permanently mark a product as "no expiry needed" for this browser. */ function _dismissNoExpiry(productId) { const m = Object.assign({}, _noExpiryDismissedCache || {}); m[String(productId)] = Date.now(); _noExpiryDismissedCache = m; _saveToServer('no_expiry_dismissed', m); } /** Remove the "no expiry needed" mark for a product (e.g. user unchecks the box). */ function _undismissNoExpiry(productId) { const m = Object.assign({}, _noExpiryDismissedCache || {}); delete m[String(productId)]; _noExpiryDismissedCache = m; _saveToServer('no_expiry_dismissed', m); } /** Build the "Ne se périme pas" toggle HTML for an expiry date input. */ function _noExpiryCheckboxHtml(expiryInputId, checked = false) { return ` `; } function toggleNoExpiryField(expiryInputId, isChecked) { const input = document.getElementById(expiryInputId); if (!input) return; input.disabled = isChecked; if (isChecked) input.value = ''; } // === ALERT BANNER SYSTEM (replaces old review table) === let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction' let _bannerIndex = 0; let _bannerLoading = false; // guard against concurrent calls let _bannerEditPending = false; // true when editing from banner → dismiss after save let _bannerRefreshTimer = null; // periodic refresh while on dashboard let _shoppingPollTimer = null; // periodic refresh while on shopping page (multi-client sync) /** * Load suspicious quantities + consumption predictions + expired + expiring soon, * merge into a single banner queue and show the first item. */ async function loadBannerAlerts() { if (_bannerLoading) return; _bannerLoading = true; _bannerQueue = []; _bannerIndex = 0; const banner = document.getElementById('alert-banner'); if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; } try { const [invData, predData, anomalyData, dupLossData, finishedData, statsData] = 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_duplicate_loss_checks').catch(err => { console.warn('[Banner] duplicate loss checks fetch failed:', err); return { checks: [] }; }), api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }), api('stats').catch(() => ({ opened: [] })), ]); const items = invData.inventory || []; const confirmed = getReviewConfirmed(); // Track item IDs already queued to prevent the same item appearing in multiple types const _queuedItemIds = new Set(); // 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 } }); _queuedItemIds.add(item.id); }); // 1b. Opened items the SERVER considers not edible (is_edible=false from stats). // The client-side getExpiredSafety check above uses conservative thresholds (e.g. // conserve are 'ok' for 30 days past), but the server uses product-specific AI shelf // life. Trust the server: any opened item with is_edible=false that isn't already // queued goes into the banner as expired. const openedNotEdible = (statsData.opened || []).filter(oi => !oi.is_edible && !_queuedItemIds.has(oi.id) && !confirmed['exp_' + oi.id]); openedNotEdible.forEach(oi => { const daysOI = Math.abs(oi.days_to_expiry ?? 0); _bannerQueue.push({ type: 'expired', data: { ...oi, days_expired: daysOI } }); _queuedItemIds.add(oi.id); }); // 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 (_queuedItemIds.has(item.id)) return; // already in expired 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 } }); _queuedItemIds.add(item.id); }); // 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. Potentially lost products due to rapid duplicate "out" events const dupChecks = dupLossData.checks || []; dupChecks.forEach(ch => { if (confirmed['dup_' + ch.dismiss_key]) return; _bannerQueue.push({ type: 'dup_loss_check', data: ch }); }); // 7. Unresolved ghosts: always show while server reports ledger/stock mismatch const finished = finishedData.finished || []; finished.forEach(fin => { _bannerQueue.push({ type: 'finished', data: fin }); }); // 8. Products with no expiry date set (and not permanently dismissed) // Warn for ALL food/drink items — only skip igiene/pulizia (non-food). // Items are capped at 8 per load (opened packages first) to avoid banner overflow. const noExpiryDismissed = _getNoExpiryDismissed(); const NON_FOOD_CATS = ['igiene', 'pulizia']; const noExpiryItems = []; items.forEach(item => { if (_queuedItemIds.has(item.id)) return; // already in expired or review if (item.expiry_date) return; // already has expiry if (parseFloat(item.quantity) <= 0) return; // no stock const pid = String(item.product_id || item.id); if (noExpiryDismissed[pid]) return; // user said "no expiry needed" const guessedCat = guessCategoryFromName(item.name || ''); const cat = (item.category || '').toLowerCase(); // Skip non-food categories if (NON_FOOD_CATS.includes(guessedCat) || NON_FOOD_CATS.some(c => cat.includes(c))) return; noExpiryItems.push(item); }); // Sort: opened packages first (more urgent), then alphabetically noExpiryItems.sort((a, b) => { if (!!a.opened_at !== !!b.opened_at) return a.opened_at ? -1 : 1; return (a.name || '').localeCompare(b.name || ''); }); noExpiryItems.slice(0, 8).forEach(item => { _bannerQueue.push({ type: 'no_expiry', data: item }); }); // 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); } finally { _bannerLoading = false; } 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 'dup_loss_check': return 700; // high-priority check: likely double-consume loss case 'finished': return 600; // product ran out — confirm before removing from DB case 'no_expiry': return 30; // low priority: informational, show after everything else 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 isOpenedExpiry = !!item.opened_at; const safety = getExpiredSafety(item, item.days_expired); let daysText, suffix; if (isOpenedExpiry) { const todayMs = new Date(); todayMs.setHours(0, 0, 0, 0); const daysSinceOpened = Math.round((todayMs - new Date(item.opened_at)) / 86400000); daysText = daysSinceOpened === 0 ? t('expiry.opened_today_long') : t('expiry.opened_ago_long').replace('{n}', daysSinceOpened); suffix = safety.level === 'ok' ? t('expiry.opened_suffix_ok') : safety.level === 'warning' ? t('expiry.opened_suffix_warning') : t('expiry.opened_suffix'); } else { daysText = item.days_expired === 0 ? t('expiry.expired_today_long') : t('expiry.expired_ago_long').replace('{n}', item.days_expired); suffix = safety.level === 'ok' ? t('expiry.expired_suffix_ok') : safety.level === 'warning' ? t('expiry.expired_suffix_warning') : t('expiry.expired_suffix'); } 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 = '✅'; } titleEl.textContent = `${item.name}${item.brand ? ' (' + item.brand + ')' : ''} ${suffix}`; let baseDetail; if (isOpenedExpiry) { const locLabel = (LOCATIONS[item.location] ? LOCATIONS[item.location].icon + ' ' + LOCATIONS[item.location].label : (item.location || '')); baseDetail = t('dashboard.banner_opened_detail') .replace('{when}', daysText) .replace('{location}', escapeHtml(locLabel)) .replace('{qty}', qtyDisplay); } else { baseDetail = t('dashboard.banner_expired_detail').replace('{when}', daysText).replace('{qty}', qtyDisplay); const locationTag = item.location ? ` · ${escapeHtml(item.location)}` : ''; const expiryTag = item.expiry_date ? ` · ${escapeHtml(item.expiry_date)}` : ''; baseDetail += locationTag + expiryTag; } detailEl.innerHTML = `${baseDetail} `; let btns = ''; btns += ``; if (!isOpenedExpiry && safety.level !== 'danger') { btns += ``; } btns += ``; // "Modifica" — opens full edit modal (includes date correction) btns += ``; if (isOpenedExpiry && !item.vacuum_sealed) { // Offer to re-seal with vacuum — extends shelf life btns += ``; } if (!isOpenedExpiry && safety.level === 'danger') { btns += ``; } if (!isOpenedExpiry) { btns += ``; } 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 = ``; if (isLow) { btns += ``; } btns += ``; if (hasScale) { btns += ``; } 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 = ``; btns += ``; if (hasScale) { btns += ``; } actionsEl.innerHTML = btns; } else if (entry.type === 'finished') { const fin = entry.data; banner.className = 'alert-banner banner-finished'; iconEl.textContent = fin.vanished ? '👻' : '📦'; const barcodeSuffix = fin.barcode && fin.barcode.length >= 3 ? ` …${escapeHtml(fin.barcode.slice(-3))}` : ''; 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 }) : ''; const baseText = fin.vanished ? t('dashboard.banner_finished_vanished') : t('dashboard.banner_finished_zero'); detailEl.innerHTML = baseText + expectedText + ' ' + t('dashboard.banner_finished_check'); let btns = ``; btns += ``; actionsEl.innerHTML = btns; } else if (entry.type === 'anomaly') { const an = entry.data; const isPhantom = an.direction === 'phantom'; banner.className = 'alert-banner banner-anomaly'; iconEl.textContent = '🔍'; 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 = ``; btns += ``; if (_geminiAvailable) { btns += ``; } actionsEl.innerHTML = btns; } else if (entry.type === 'dup_loss_check') { const ch = entry.data; banner.className = 'alert-banner banner-dup-loss'; iconEl.textContent = '🧪'; const locInfo = LOCATIONS[ch.location] || { icon: '📦', label: ch.location || '—' }; const locText = `${locInfo.icon} ${locInfo.label}`; const qtyPair = `${ch.q1} + ${ch.q2}`; titleEl.textContent = t('dashboard.banner_dup_loss_title').replace('{name}', ch.name); detailEl.textContent = t('dashboard.banner_dup_loss_detail') .replace('{location}', locText) .replace('{seconds}', Math.round(ch.dt_sec || 0)) .replace('{qty_pair}', qtyPair); let btns = ''; if (ch.inventory_id && ch.inventory_id > 0) { btns += ``; } else { btns += ``; } btns += ``; actionsEl.innerHTML = btns; } else if (entry.type === 'no_expiry') { const item = entry.data; banner.className = 'alert-banner banner-no-expiry'; iconEl.textContent = '📅'; titleEl.textContent = t('dashboard.banner_no_expiry_title').replace('{name}', item.name + (item.brand ? ' (' + item.brand + ')' : '')); detailEl.textContent = t('dashboard.banner_no_expiry_detail'); const pid = item.product_id || item.id; let btns = ``; btns += ``; actionsEl.innerHTML = btns; } if (_bannerQueue.length > 1) { let dots = ``; dots += _bannerQueue.map((_, i) => `` ).join(''); dots += ``; 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 confirmNoExpiryNeeded(productId) { _dismissNoExpiry(productId); showToast(t('dashboard.banner_no_expiry_toast_dismissed'), 'success'); dismissBannerItem(); } function editBannerNoExpiry() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'no_expiry') return; _bannerEditPending = true; api('inventory_list').then(data => { currentInventory = data.inventory || []; editInventoryItem(entry.data.id); }); } 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(t('dashboard.banner_prediction_confirmed'), '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 = `${t('dashboard.banner_analyzing')}`; // 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 = `\ud83e\udd16 ${escapeHtml(result.explanation)}`; } else { detailEl.innerHTML = originalHtml; showToast(t('dashboard.banner_anomaly_explain_fail'), 'error'); } } catch (e) { detailEl.innerHTML = originalHtml; showToast(t('error.generic'), '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(t('dashboard.banner_anomaly_dismissed'), 'info'); dismissBannerItem(); } function dismissDuplicateLossCheck() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'dup_loss_check') return; const key = entry.data.dismiss_key; setReviewConfirmed('dup_' + key); showToast(t('dashboard.banner_dup_loss_toast_done'), 'success'); dismissBannerItem(); } async function openDuplicateLossCheck(productId) { showLoading(true); try { const data = await api('product_get', { id: productId }); if (data.product) { currentProduct = data.product; showProductAction(); } else { showToast(t('error.not_found'), 'error'); } } catch (e) { showToast(t('error.connection'), 'error'); } finally { showLoading(false); } } 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; // Populate currentProduct so the shared showThrowForm / throwAll / throwPartial work currentProduct = { id: item.product_id, name: item.name, brand: item.brand || '', image_url: item.image_url || null, category: item.category || '', unit: item.unit || 'pz', default_quantity: item.default_quantity || 0, package_unit: item.package_unit || '' }; showThrowForm(); } async function bannerMarkVacuum() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'expired') return; const item = entry.data; if (item.vacuum_sealed) return; // already sealed // Calculate new expiry: opened_at + opened_shelf_life_days_with_vacuum let newExpiry = null; if (item.opened_at) { // estimateOpenedExpiryDays returns days without vacuum; add 50% for vacuum sealed const baseDays = estimateOpenedExpiryDays( { name: item.name, category: item.category || '' }, item.location ); const vacuumDays = Math.round(baseDays * 1.5); const d = new Date(item.opened_at); d.setDate(d.getDate() + vacuumDays); newExpiry = d.toISOString().slice(0, 10); } const body = { id: item.id, vacuum_sealed: 1 }; if (newExpiry) body.expiry_date = newExpiry; try { const res = await api('inventory_update', {}, 'POST', body); if (res.success || res.ok) { showToast(t('toast.vacuum_sealed', { name: item.name }), 'success'); dismissBannerItem(); loadDashboard(); } else { showToast(res.error || t('error.generic'), 'error'); } } catch(e) { showToast(t('error.connection'), 'error'); } } 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(t('toast.finished_all').replace('{name}', item.name), 'success'); showLowStockBringPrompt(res, () => loadDashboard()); } else { showToast(res.error || t('error.generic'), '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 { const res = await api('inventory_confirm_finished', {}, 'POST', { product_id: productId }); if (res.bring?.added || res.bring?.updated) { showToast(t('toast.finished_to_bring'), 'info'); loadShoppingList(); } } catch(e) {} showToast(t('toast.product_finished_confirmed'), 'success'); dismissBannerItem(); if (typeof loadDashboard === 'function') loadDashboard(); } async function notFinishedBannerAction() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'finished') return; const fin = entry.data; const qty = fin.expected_qty; const customQty = prompt( t('dashboard.banner_finished_restore_prompt', { name: fin.name, qty, unit: fin.unit }), String(qty) ); if (customQty === null) return; const parsed = parseFloat(String(customQty).replace(',', '.')); if (isNaN(parsed) || parsed <= 0) { showToast(t('error.invalid_quantity'), 'error'); return; } showLoading(true); try { await api('inventory_restore_ghost', {}, 'POST', { product_id: fin.product_id, quantity: parsed, location: fin.location || 'dispensa', }); showToast(t('toast.ghost_restored', { name: fin.name, qty: parsed, unit: fin.unit }), 'success'); dismissBannerItem(); if (typeof loadDashboard === 'function') loadDashboard(); } catch (e) { showToast(t('error.connection'), 'error'); } finally { showLoading(false); } } // --- 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 += `
${label} ${catItems.length}
`; 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, item.display_unit_key); 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 `
${item.image_url ? `` : catIcon}
${escapeHtml(item.name)}
${item.brand ? `
${escapeHtml(item.brand)}
` : ''}
${parts.mainQty} ${parts.unitLabel} ${parts.packageDetail ? `${parts.packageDetail}` : ''} ${expiryLabel ? `${expiryLabel}` : ''}
`; } 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': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.conf') }; const label = unitLabels[unit] || unit || t('units.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} ${t('units.conf') || 'conf'} (${t('units.from') || 'da'} ${defaultQty}${pkgLabel})`; } const remainderText = formatSubRemainder(fractionalConf * defaultQty, packageUnit); if (wholeConf > 0) { return `${wholeConf} ${t('units.conf') || 'conf'} (${t('units.from') || 'da'} ${defaultQty}${pkgLabel}) + ${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; } /** Human-readable unit label for quantity inputs (pz, g, ml, conf…). */ function getUnitDisplayLabel(unit) { const u = (unit || 'pz').toLowerCase(); const map = { pz: t('units.pz') || 'pz', g: 'g', ml: 'ml', conf: t('units.conf') || 'conf', kg: 'kg', l: 'l', }; return map[u] || u; } /** Wrap qty input with a visible unit badge if missing. */ function ensureQtyUnitBadge(inputId) { const input = document.getElementById(inputId); if (!input) return null; let badge = document.getElementById(inputId + '-unit'); if (badge) return badge; const control = input.closest('.qty-control'); if (!control) return null; let wrap = control.closest('.qty-control-with-unit'); if (!wrap) { wrap = document.createElement('div'); wrap.className = 'qty-control-with-unit'; control.parentNode.insertBefore(wrap, control); wrap.appendChild(control); } badge = document.createElement('span'); badge.className = 'qty-unit-badge'; badge.id = inputId + '-unit'; badge.setAttribute('aria-live', 'polite'); badge.textContent = '—'; wrap.appendChild(badge); return badge; } function setQtyInputUnitLabel(inputId, unit, muted = false) { const badge = ensureQtyUnitBadge(inputId) || document.getElementById(inputId + '-unit'); if (!badge) return; badge.textContent = getUnitDisplayLabel(unit); badge.classList.toggle('qty-unit-muted', !!muted); badge.title = (t('qty.unit_for_input') || 'Unità di misura') + ': ' + badge.textContent; } function getActiveUseUnitLabel() { if (_useConfMode) { if (_useConfMode._activeUnit === 'conf') return 'conf'; return _useConfMode.subLabel || _useConfMode.packageUnit || 'g'; } return _useNormalUnit || 'pz'; } function getActiveRecipeUseUnitLabel() { if (_recipeUseConfMode) { if (_recipeUseConfMode._activeUnit === 'conf') return 'conf'; return _recipeUseConfMode.subLabel || _recipeUseConfMode.packageUnit || 'g'; } return _recipeUseNormalUnit || 'pz'; } function syncUseQtyUnitBadge() { setQtyInputUnitLabel('use-quantity', getActiveUseUnitLabel()); } function syncRecipeUseQtyUnitBadge() { setQtyInputUnitLabel('ruse-quantity', getActiveRecipeUseUnitLabel()); } // Structured quantity display for inventory cards. // Returns { mainQty: '10', unitLabel: 'conf', packageDetail: 'da 36g', fraction: '¼' } function formatQuantityParts(qty, unit, defaultQty, packageUnit, displayUnitKey) { let n = parseFloat(qty) || 0; const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.conf') }; const customUnit = displayUnitKey ? CUSTOM_UNITS.find(u => u.key === displayUnitKey && u.base_unit === unit) : null; let label; if (customUnit) { n = n / customUnit.factor; label = `${customUnit.icon} ${customUnit.label}`.trim(); } else { label = unitLabels[unit] || unit || t('units.pz'); } // Special handling for conf with partial packages (custom units never apply to conf) if (!customUnit && 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: t('units.conf') || 'conf', packageDetail: `${t('units.from') || 'da'} ${defaultQty}${pkgLabel}`, fraction: '' }; } const remainderText = formatSubRemainder(fractionalConf * defaultQty, packageUnit); if (wholeConf > 0) { return { mainQty: `${wholeConf}`, unitLabel: t('units.conf') || 'conf', packageDetail: `${t('units.from') || 'da'} ${defaultQty}${pkgLabel}`, fraction: `+ ${remainderText}` }; } return { mainQty: remainderText, unitLabel: '', packageDetail: '', fraction: '' }; } let mainQty; if (n === Math.floor(n)) mainQty = `${Math.floor(n)}`; else if (!customUnit && unit === 'pz') mainQty = _pzFractionLabel(n); else mainQty = `${n.toFixed(customUnit ? 2 : 1)}`; let packageDetail = ''; let fraction = ''; // pz = piece count only; default_quantity may hold legacy avg weight — ignore for display if (!customUnit && unit !== 'conf' && unit !== 'pz' && 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 `${frac}`; } // ===== 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 catKey = mapToLocalCategory(item.category, item.name); const catIcon = CATEGORY_ICONS[catKey] || '📦'; const catLabel = t('categories.' + catKey) || catKey; const catBadge = `${catIcon} ${catLabel}`; const subBadge = (item.subcategory && SUBCATEGORY_LABEL_LOOKUP[catKey + '::' + item.subcategory]) ? `${SUBCATEGORY_LABEL_LOOKUP[catKey + '::' + item.subcategory]}` : ''; 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, item.display_unit_key); 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 = `${expiryText}`; } const vacuumBadge = item.vacuum_sealed ? `${t('inventory.vacuum_badge')}` : ''; const openedBadge = item.opened_at ? `${t('inventory.opened_badge')}` : ''; return `
${item.image_url ? `` : catIcon}
${escapeHtml(item.name)}
${item.brand ? `
${escapeHtml(item.brand)}
` : ''}
${locInfo.icon} ${locInfo.label} ${catBadge} ${subBadge} ${expiryBadge} ${openedBadge} ${vacuumBadge}
${parts.mainQty} ${parts.unitLabel}${parts.packageDetail ? ` ${parts.packageDetail}` : ''} ${parts.fraction ? `${parts.fraction}` : ''}
`; } function renderInventory(items) { const container = document.getElementById('inventory-list'); if (items.length === 0) { container.innerHTML = `
📭

${t('inventory.empty_text')}

`; return; } container.innerHTML = renderGroupedByCategory(items, false); _refineCategoryBadgesAsync(); } /** * After rendering, find all badges still showing 'altro' and ask the server * (Gemini-backed, cached) for a better category. Updates the DOM in place. */ async function _refineCategoryBadgesAsync() { if (!_geminiAvailable) return; // AI not available — keep 'altro' label const badges = Array.from(document.querySelectorAll('.badge-category[data-cat="altro"]')); for (const badge of badges) { const name = badge.dataset.itemname; if (!name) continue; try { const res = await api('guess_category', { name }); const cat = res.category; if (cat && cat !== 'altro') { badge.dataset.cat = cat; badge.textContent = (CATEGORY_ICONS[cat] || '📦') + ' ' + (t('categories.' + cat) || cat); } } catch (_) { /* network error — leave as 'altro' */ } } } 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'; // Category inferred from the search term itself (e.g. "biscotti" → "snack") const queryCat = guessCategoryFromName(q); const filtered = currentInventory.filter(i => { if (i.name.toLowerCase().includes(q)) return true; if (i.brand && i.brand.toLowerCase().includes(q)) return true; if (i.barcode && i.barcode.includes(q)) return true; const itemCat = mapToLocalCategory(i.category, i.name); // Match category key directly (e.g. "snack", "latticini") if (itemCat.includes(q)) return true; // Match category label (e.g. "dolci" matches "Snack & Dolci", "riso" matches "Pasta & Riso") if ((CATEGORY_LABELS[itemCat] || '').toLowerCase().includes(q)) return true; // Match by inferred category: "biscotti" → queryCat="snack" → all snack items if (queryCat !== 'altro' && itemCat === queryCat) return true; if (i.subcategory && i.subcategory.toLowerCase().includes(q)) return true; if (i.subcategory && (SUBCATEGORY_LABEL_LOOKUP[itemCat + '::' + i.subcategory] || '').toLowerCase().includes(q)) return true; return false; }); renderInventory(filtered); } // ===== QUICK ACCESS: RECENT & POPULAR ===== async function loadQuickAccess() { const section = document.getElementById('quick-access-section'); if (!section) return; try { const data = await api('recent_popular_products'); const recent = data.recent || []; const popular = data.popular || []; const recentIds = data.recent_ids || []; const recentGroup = document.getElementById('quick-recent-group'); const popularGroup = document.getElementById('quick-popular-group'); const recentGrid = document.getElementById('quick-recent-grid'); const popularGrid = document.getElementById('quick-popular-grid'); // Render recent (max 4) if (recent.length > 0) { recentGrid.innerHTML = recent.slice(0, 4).map(p => renderQuickAccessBtn(p)).join(''); recentGroup.style.display = ''; } else { recentGroup.style.display = 'none'; } // Render popular (max 8), excluding products already in recent const filteredPopular = popular.filter(p => !recentIds.includes(parseInt(p.product_id))); if (filteredPopular.length > 0) { popularGrid.innerHTML = filteredPopular.slice(0, 8).map(p => renderQuickAccessBtn(p)).join(''); popularGroup.style.display = ''; } else { popularGroup.style.display = 'none'; } section.style.display = (recent.length > 0 || filteredPopular.length > 0) ? '' : 'none'; } catch (e) { console.warn('[QuickAccess] load failed:', e); section.style.display = 'none'; } } function renderQuickAccessBtn(product) { const catIcon = CATEGORY_ICONS[mapToLocalCategory(product.category, product.name)] || '📦'; const imgHtml = product.image_url ? `` : catIcon; const brandHtml = product.brand ? `(${escapeHtml(product.brand)})` : ''; return ` `; } function quickAccessSelect(productId) { // Find the product in current inventory and show its detail const item = currentInventory.find(i => i.product_id === productId); if (item) { showItemDetail(item.id, item.product_id); } else { // Product not in current view (maybe different location), navigate to it quickUse(productId, currentLocation || 'dispensa'); } } // ===== ITEM DETAIL MODAL ===== function showItemDetail(inventoryId, productId) { const item = currentInventory.find(i => i.id === inventoryId); 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 = `
${item.image_url ? `` : `${catIcon}` }

${escapeHtml(item.name)}

${item.brand ? escapeHtml(item.brand) : ''}

`; document.getElementById('modal-overlay').style.display = 'flex'; } function closeModal() { document.getElementById('modal-overlay').style.display = 'none'; clearMoveModalTimer(); _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'); } } const WASTE_REASON_KEYS = ['expired', 'spoiled', 'wrong_location', 'kept_too_long', 'bought_too_much', 'forgotten', 'bad_quality', 'other']; function _wasteNotesForReason(reason) { return 'Buttato|' + reason; } function _showWasteReasonModal(productLabel, onPick) { const buttons = WASTE_REASON_KEYS.map(r => `` ).join(''); document.getElementById('modal-content').innerHTML = `

${escapeHtml(productLabel)}

${t('waste.reason_subtitle')}

${buttons}
`; document.getElementById('modal-overlay').style.display = 'flex'; document.querySelectorAll('[data-waste-reason]').forEach(btn => { btn.addEventListener('click', () => { const reason = btn.getAttribute('data-waste-reason'); closeModal(); onPick(reason); }); }); } function _inventoryWaste(payload, productLabel) { return new Promise((resolve, reject) => { _showWasteReasonModal(productLabel || '', async (reason) => { showLoading(true); try { const result = await api('inventory_use', {}, 'POST', { ...payload, notes: _wasteNotesForReason(reason) }); resolve(result); } catch (e) { reject(e); } finally { showLoading(false); } }); }); } async function deleteInventoryItem(id) { const item = currentInventory.find(i => i.id === id); const unit = item ? (item.unit || 'pz') : 'pz'; const qty = item ? (parseFloat(item.quantity) || 0) : 0; const canDiscardOne = item && (unit === 'pz' || unit === 'conf') && qty > 1; if (!canDiscardOne) { // Simple case: confirm → delete the whole row if (confirm(t('confirm.remove_item'))) { await api('inventory_delete', {}, 'POST', { id }); closeModal(); showToast(t('toast.product_removed'), 'success'); refreshCurrentPage(); } return; } // Show a choice modal: 1 piece vs everything const qtyDisplay = formatQuantity(qty, unit, item.default_quantity, item.package_unit); document.getElementById('modal-content').innerHTML = `

${escapeHtml(item.name)} · ${qtyDisplay}

`; document.getElementById('modal-overlay').style.display = 'flex'; } async function _discardOnePiece(inventoryId) { const item = currentInventory.find(i => i.id === inventoryId); if (!item) { closeModal(); return; } closeModal(); try { await _inventoryWaste({ product_id: item.product_id, quantity: 1, location: item.location, }, item.name); showToast(t('toast.thrown_away_partial', { qty: 1, unit: item.unit || 'pz', name: item.name }), 'success'); refreshCurrentPage(); } catch(e) { showToast(t('error.connection'), 'error'); } } async function _discardAllFromModal(inventoryId) { const item = currentInventory.find(i => i.id === inventoryId); if (!item) { closeModal(); return; } closeModal(); try { await _inventoryWaste({ product_id: item.product_id, use_all: true, location: item.location, }, item.name); showToast(t('toast.thrown_away', { name: item.name }), 'success'); refreshCurrentPage(); } catch(e) { showToast(t('error.connection'), 'error'); } } /** Track manual expiry edits — auto-recalc on location/vacuum must not overwrite user dates. */ function _initExpiryManualTracking(inputId, item) { const el = document.getElementById(inputId); if (!el) return; if (item?.expiry_user_set) el.dataset.manuallySet = 'true'; else delete el.dataset.manuallySet; if (el.dataset.expiryTrackBound) return; el.dataset.expiryTrackBound = '1'; const mark = () => { if (el.value) el.dataset.manuallySet = 'true'; else delete el.dataset.manuallySet; }; el.addEventListener('input', mark); el.addEventListener('change', mark); } function _isExpiryManuallySet(inputId) { return document.getElementById(inputId)?.dataset.manuallySet === 'true'; } function _expiryUserSetPayload(inputId) { return _isExpiryManuallySet(inputId) ? 1 : 0; } function recalcEditExpiry(locInputId, vacuumInputId, expiryInputId) { if (_isExpiryManuallySet(expiryInputId)) return; 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, _prevUnit: item.unit || 'pz', default_quantity: item.default_quantity, package_unit: item.package_unit || '', }; const itemCatForEdit = mapToLocalCategory(item.category, item.name, item.brand); const itemSubcatOptions = getSubcategoryOptionsHtml(itemCatForEdit, item.subcategory || ''); const itemSubcatVisible = 'block'; const itemSubcatRequired = REQUIRED_SUBCATEGORY_CATEGORIES.includes(itemCatForEdit); let itemTagsSelected = []; try { itemTagsSelected = JSON.parse(item.tags || '[]'); } catch (e) { itemTagsSelected = []; } // Rebuild modal content for editing (don't close and reopen - just replace content) document.getElementById('modal-content').innerHTML = `
${RECIPE_TAGS.map(tag => { const selected = itemTagsSelected.includes(tag.key); return ``; }).join('')}
${escapeHtml(getUnitDisplayLabel(item.unit || 'pz'))}
${scaleEditReady ? ` ` : ''}
${Object.entries(LOCATIONS).map(([k, v]) => ` `).join('')}
${_noExpiryCheckboxHtml('edit-expiry', !item.expiry_date && !!_getNoExpiryDismissed()[String(item.product_id)])}
`; document.getElementById('modal-overlay').style.display = 'flex'; _initExpiryManualTracking('edit-expiry', item); setQtyInputUnitLabel('edit-qty', item.unit || 'pz'); } function onEditInvCategoryChange() { const cat = document.getElementById('edit-inv-category')?.value || ''; updateSubcategoryField('edit-inv-subcategory', 'edit-inv-subcategory-group', cat); } function onEditUnitChange() { const unit = document.getElementById('edit-unit').value; setQtyInputUnitLabel('edit-qty', unit); const confGroup = document.getElementById('edit-conf-size-group'); if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none'; if (unit === 'conf') { const sizeInput = document.getElementById('edit-conf-size'); const confUnitSel = document.getElementById('edit-conf-unit'); if (sizeInput && !parseFloat(sizeInput.value)) { const ep = window._editingProduct || {}; const defQty = parseFloat(ep.default_quantity) || 0; const pkgUnit = ep.package_unit || ''; const prevUnit = ep._prevUnit || ''; if (defQty > 0 && pkgUnit) { sizeInput.value = defQty; if (confUnitSel) confUnitSel.value = pkgUnit; } else if (defQty > 0 && ['g', 'ml'].includes(prevUnit)) { sizeInput.value = defQty; if (confUnitSel) confUnitSel.value = prevUnit; } } } } async function submitEditInventory(e, id, productId) { e.preventDefault(); const category = document.getElementById('edit-inv-category')?.value || ''; const subcategory = document.getElementById('edit-inv-subcategory')?.value || ''; if (REQUIRED_SUBCATEGORY_CATEGORIES.includes(category) && !subcategory) { showToast('Merci de préciser la sous-catégorie', 'error'); document.getElementById('edit-inv-subcategory')?.focus(); return; } const { realUnit, factor, displayUnitKey } = resolveUnitSelection('edit-unit'); const qty = (parseFloat(document.getElementById('edit-qty').value) || 0) * factor; const loc = document.getElementById('edit-loc').value; const noExpiryChecked = document.getElementById('edit-expiry-no-expiry')?.checked || false; if (noExpiryChecked) _dismissNoExpiry(productId); else _undismissNoExpiry(productId); const expiry = noExpiryChecked ? null : (document.getElementById('edit-expiry').value || null); const unit = realUnit; if (unit === 'conf') { const confSize = parseFloat(document.getElementById('edit-conf-size')?.value); if (!confSize || confSize <= 0) { showToast(t('product.conf_size_required'), 'error'); document.getElementById('edit-conf-size')?.focus(); return; } } // Safety guard: warn if quantity is unreasonably large to prevent unit-confusion errors // (e.g. user types "183" thinking it's ml, but the field expects conf units) const _largeQtyLimits = { conf: 50, pz: 200, g: 10000, ml: 10000 }; const _largeQtyLimit = _largeQtyLimits[unit] ?? 500; if (qty > _largeQtyLimit) { if (!confirm(t('edit.confirm_large_qty').replace('{qty}', qty).replace('{unit}', unit))) return; } const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId, display_unit_key: displayUnitKey, vacuum_sealed: document.getElementById('edit-vacuum')?.checked ? 1 : 0, expiry_user_set: _expiryUserSetPayload('edit-expiry') }; // 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; } const editedItem = currentInventory.find(i => i.id === id) || {}; const barcode = (document.getElementById('edit-inv-barcode')?.value || '').trim(); const editedName = (document.getElementById('edit-inv-name')?.value || '').trim() || editedItem.name; const selectedTags = Array.from(document.querySelectorAll('#edit-inv-tags-picker .inv-tag-chip.btn-primary')).map(b => b.dataset.tag); await api('product_save', {}, 'POST', { id: productId, barcode: barcode || null, name: editedName, brand: editedItem.brand || '', category: category, subcategory: subcategory || null, image_url: editedItem.image_url || '', unit: editedItem.unit || 'pz', default_quantity: editedItem.default_quantity || 1, package_unit: editedItem.package_unit || '', notes: editedItem.notes || '', tags: selectedTags, }); await api('inventory_update', {}, 'POST', payload); closeModal(); showToast(t('toast.updated'), '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); function _scannerAiAllowed() { return _geminiAvailable && !_offlineMode && navigator.onLine; } function pauseScanner() { _scannerPaused = true; _updateScanAiButton(); } function resumeScanner() { if (!scannerStream) { initScanner(); return; } _scannerPaused = false; const video = document.getElementById('scanner-video'); if (video && video.paused) video.play().catch(() => {}); if (!quaggaRunning) { _startBestScanner(video); } _setScanStatus(t('scan.status_scanning'), '', ''); _updateScanAiButton(); } 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)' : 'will use ZBar WASM'}`); scanLog(`ZBar polyfill: ${typeof barcodeDetectorPolyfill !== 'undefined' ? 'YES' : 'NO'}`); scanLog(`Gemini available: ${_geminiAvailable}`); scanLog(`AI manual button: ${_scannerAiAllowed() ? 'YES' : 'NO'}`); scanLog(`Constraints: ${JSON.stringify(constraints.video)}`); try { stopScanner(); _resetAiFallbackForNewScan(); _clearAiMatchPanel(); 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}`); // Apply fixed 2x zoom await _applyFixedZoom(); _invalidBarcodeCount = 0; _setScanStatus(t('scan.status_ready'), '', ''); await _ensureBarcodeEngines(); _scannerPaused = false; _startBestScanner(video); setTimeout(() => _ensureTesseractWorker().catch(() => {}), 1200); _updateScanAiButton(); } 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 = `

${t('error.camera')}

${t('scanner.camera_error_hint')}

`; } } // ===== EAN-13 / EAN-8 CHECKSUM VALIDATOR ===== function validateEANChecksum(code) { const s = String(code).replace(/\D/g, ''); if (s.length !== 13 && s.length !== 8) return false; const digits = s.split('').map(Number); const last = digits.pop(); const sum = digits.reduce((acc, d, i) => { return acc + d * (s.length === 13 ? (i % 2 === 0 ? 1 : 3) : (i % 2 === 0 ? 3 : 1)); }, 0); const check = (10 - (sum % 10)) % 10; return check === last; } // ===== SCAN STATUS BAR ===== let _invalidBarcodeCount = 0; function _setScanStatus(msg, state, method) { const msgEl = document.getElementById('scan-status-msg'); const methodEl = document.getElementById('scan-status-method'); if (msgEl) { msgEl.textContent = msg || ''; msgEl.className = 'scan-status-msg' + (state ? ' state-' + state : ''); } if (methodEl && method !== undefined) methodEl.textContent = method || ''; } // ===== BARCODE ENGINE INIT (Native + ZBar WASM) ===== let _zbarVendorPromise = null; /** Lazy-load ZBar WASM (skipped at page load on kiosk WebView to reduce OOM risk). */ function _loadZbarVendor() { if (typeof barcodeDetectorPolyfill !== 'undefined') return Promise.resolve(); if (_zbarVendorPromise) return _zbarVendorPromise; _zbarVendorPromise = new Promise((resolve, reject) => { const done = () => { if (typeof barcodeDetectorPolyfill !== 'undefined') resolve(); else reject(new Error('ZBar polyfill unavailable')); }; const loadPoly = () => { const s2 = document.createElement('script'); s2.src = 'assets/vendor/zbar/polyfill.js?v=20260606a'; s2.onload = done; s2.onerror = () => reject(new Error('ZBar polyfill load failed')); document.head.appendChild(s2); }; if (window.zbarWasm) { loadPoly(); return; } const s1 = document.createElement('script'); s1.src = 'assets/vendor/zbar/index.js?v=20260606a'; s1.onload = () => { if (window.zbarWasm && zbarWasm.setModuleArgs) { zbarWasm.setModuleArgs({ locateFile: (file) => 'assets/vendor/zbar/' + file }); } loadPoly(); }; s1.onerror = () => reject(new Error('ZBar WASM load failed')); document.head.appendChild(s1); }).catch(err => { _zbarVendorPromise = null; throw err; }); return _zbarVendorPromise; } function _startBestScanner(videoEl) { if (_detectorNative || _detectorZbar) { startUnifiedScanner(videoEl); } else if (typeof Quagga !== 'undefined') { scanLog('Legacy Quagga fallback — no native/ZBar engine'); startQuaggaScanner(videoEl); } else { scanLog('No barcode engine available'); _setScanStatus(t('error.camera'), 'invalid', ''); } } function _ensureBarcodeEngines() { if (_barcodeEnginesReady) return _barcodeEnginesReady; _barcodeEnginesReady = (async () => { _detectorNative = null; _detectorZbar = null; if ('BarcodeDetector' in window) { try { const supported = await BarcodeDetector.getSupportedFormats(); const formats = _SCAN_FORMATS.filter(f => supported.includes(f)); if (formats.length) { _detectorNative = new BarcodeDetector({ formats }); _useBarcodeDetector = true; } } catch (e) { scanLog(`Native BarcodeDetector init failed: ${e.message}`); } } if (typeof barcodeDetectorPolyfill === 'undefined') { try { await _loadZbarVendor(); } catch (e) { scanLog(`ZBar vendor load failed: ${e.message}`); } } if (typeof barcodeDetectorPolyfill !== 'undefined') { try { _detectorZbar = new barcodeDetectorPolyfill.BarcodeDetectorPolyfill({ formats: _SCAN_FORMATS }); } catch (e) { scanLog(`ZBar engine init failed: ${e.message}`); } } scanLog(`Engines ready — native: ${!!_detectorNative}, ZBar: ${!!_detectorZbar}`); if (_detectorZbar) { try { const warm = document.createElement('canvas'); warm.width = warm.height = 8; await _detectorZbar.detect(warm); } catch (_) {} } })(); return _barcodeEnginesReady; } const _EAN_FORMATS = new Set(['ean_13', 'ean_8', 'upc_a', 'upc_e', 'ean_reader', 'ean_8_reader', 'upc_reader', 'upc_e_reader']); function _isEanFormat(format) { return format && (_EAN_FORMATS.has(format) || String(format).includes('ean') || String(format).includes('upc')); } let _scanLastCode = ''; let _scanConsecCount = 0; function _finalizeBarcode(code, method) { _scannerGen++; quaggaRunning = false; const scannerLine = document.querySelector('.scanner-line'); if (scannerLine) scannerLine.classList.remove('scanning', 'detecting'); scanLog(`CONFIRMED: ${code} via ${method}`); _hideScanLiveCode(); _setScanStatus(t('scan.status_confirmed'), 'confirmed', method); onBarcodeDetected(code); } function _tryConfirmBarcode(code, format, method) { const digits = String(code || '').replace(/\D/g, ''); if (!digits || digits.length < 8) return false; const isEan = _isEanFormat(format) || digits.length === 13 || digits.length === 8; if (isEan && (digits.length === 13 || digits.length === 8)) { if (!validateEANChecksum(digits)) { _invalidBarcodeCount++; scanLog(`Invalid EAN checksum (${method}): ${digits}`); _setScanStatus(t('scan.status_invalid').replace('{code}', digits), 'invalid', method); _scanLastCode = ''; _scanConsecCount = 0; return false; } _finalizeBarcode(digits, method); return true; } if (digits === _scanLastCode) _scanConsecCount++; else { _scanLastCode = digits; _scanConsecCount = 1; } _showScanLiveCode(digits); _setScanStatus(t('scan.status_partial').replace('{code}', digits), 'partial', method); if (_scanConsecCount >= 2) { _finalizeBarcode(digits, method); return true; } return false; } function _extractEanCandidates(text) { const digits = String(text || '').replace(/\D/g, ''); const found = []; for (const len of [13, 8]) { if (digits.length < len) continue; for (let i = 0; i <= digits.length - len; i++) { const candidate = digits.slice(i, i + len); if (validateEANChecksum(candidate) && !found.includes(candidate)) found.push(candidate); } } return found; } // Build preprocessed crop frames for ZBar (rotates through variants) function _buildScanCropFrame(videoEl, frameCount) { const vw = videoEl.videoWidth; const vh = videoEl.videoHeight; if (!vw || !vh) return null; const canvas = document.getElementById('scanner-canvas'); const ctx = canvas.getContext('2d'); const variants = [ { x: 0, y: 0, w: 1, h: 1, scale: 0.85, enhance: false }, { x: 0.075, y: 0.28, w: 0.85, h: 0.44, scale: 1, enhance: true }, { x: 0.1, y: 0.18, w: 0.8, h: 0.55, scale: 1, enhance: true }, { x: 0.05, y: 0.35, w: 0.9, h: 0.35, scale: 1.2, enhance: true }, ]; const v = variants[frameCount % variants.length]; const sw = Math.round(vw * v.w); const sh = Math.round(vh * v.h); const sx = Math.round(vw * v.x); const sy = Math.round(vh * v.y); canvas.width = Math.round(sw * v.scale); canvas.height = Math.round(sh * v.scale); ctx.drawImage(videoEl, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); if (v.enhance || _offlineMode || !navigator.onLine || isFrontCamera()) { enhanceCanvasForBarcode(ctx, canvas.width, canvas.height); } return canvas; } function _captureDigitStrip(videoEl) { const vw = videoEl.videoWidth; const vh = videoEl.videoHeight; if (!vw || !vh) return null; const canvas = document.getElementById('scanner-canvas'); const ctx = canvas.getContext('2d'); const cropW = Math.round(vw * 0.9); const cropH = Math.round(vh * 0.5); const sx = Math.round((vw - cropW) / 2); const sy = Math.round(vh * 0.2); const scale = 2; canvas.width = cropW * scale; canvas.height = cropH * scale; ctx.imageSmoothingEnabled = false; ctx.drawImage(videoEl, sx, sy, cropW, cropH, 0, 0, canvas.width, canvas.height); enhanceCanvasForBarcode(ctx, canvas.width, canvas.height); return canvas; } async function _ensureTesseractWorker() { if (_tesseractWorker) return _tesseractWorker; if (_tesseractInitPromise) return _tesseractInitPromise; _tesseractInitPromise = (async () => { if (typeof Tesseract === 'undefined') { await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'assets/vendor/tesseract/tesseract.min.js'; s.onload = resolve; s.onerror = () => reject(new Error('Tesseract load failed')); document.head.appendChild(s); }); } const base = 'assets/vendor/tesseract'; const worker = await Tesseract.createWorker('eng', 1, { workerPath: `${base}/worker.min.js`, corePath: `${base}/tesseract-core.wasm.js`, langPath: `${base}/lang`, gzip: true, }); await worker.setParameters({ tessedit_char_whitelist: '0123456789', tessedit_pageseg_mode: '7', }); _tesseractWorker = worker; scanLog('Local digit OCR engine ready'); return worker; })().catch(e => { scanLog(`Local OCR init failed: ${e.message}`); _tesseractInitPromise = null; return null; }); return _tesseractInitPromise; } async function _tryLocalEanDigitOcr(videoEl) { if (_scannerPaused || !scannerStream) return false; const worker = await _ensureTesseractWorker(); if (!worker) return false; const canvas = _captureDigitStrip(videoEl); if (!canvas) return false; _setScanStatus(t('scan.status_digit_ocr'), 'retry', t('scan.method_local_ocr')); try { const { data } = await worker.recognize(canvas); const candidates = _extractEanCandidates(data && data.text); if (candidates.length) { scanLog(`Local OCR: found ${candidates[0]} (from "${(data.text || '').trim()}")`); showToast(t('scan.local_ocr_found').replace('{code}', candidates[0]), 'success'); return _tryConfirmBarcode(candidates[0], 'ean_13', 'OCR'); } scanLog(`Local OCR: no valid EAN in "${(data && data.text) || ''}"`); } catch (e) { scanLog(`Local OCR error: ${e.message}`); } return false; } // ===== UNIFIED SCANNER (Native + ZBar + local digit OCR) ===== function startUnifiedScanner(videoEl) { const gen = ++_scannerGen; quaggaRunning = true; _scanLastCode = ''; _scanConsecCount = 0; const startTime = Date.now(); let frameCount = 0; let digitOcrBusy = false; let lastDigitOcrAt = 0; const scannerLine = document.querySelector('.scanner-line'); scanLog('Unified scanner started'); function feedback(state) { if (!scannerLine) return; scannerLine.classList.remove('scanning', 'detecting'); if (state) scannerLine.classList.add(state); } async function loop() { if (gen !== _scannerGen || !scannerStream) { quaggaRunning = false; return; } if (_scannerPaused) { requestAnimationFrame(loop); return; } frameCount++; if (frameCount === 1) { feedback('scanning'); _setScanStatus(t('scan.status_scanning'), '', _detectorNative ? 'Native+ZBar' : 'ZBar'); } // 1) Native BarcodeDetector — every frame (fastest when available) if (_detectorNative) { try { const codes = await _detectorNative.detect(videoEl); if (codes && codes.length > 0) { feedback('detecting'); if (_tryConfirmBarcode(codes[0].rawValue, codes[0].format, 'Native')) return; } } catch (e) { if (frameCount === 1) scanLog(`Native detect error: ${e.message}`); } } // 2) ZBar WASM on live video — every frame offline, every 2 online const zbarEvery = (_offlineMode || !navigator.onLine) ? 1 : 2; if (_detectorZbar && frameCount % zbarEvery === 0) { try { const codes = await _detectorZbar.detect(videoEl); if (codes && codes.length > 0) { feedback('detecting'); if (_tryConfirmBarcode(codes[0].rawValue, codes[0].format, 'ZBar')) return; } } catch (e) { if (frameCount <= 2) scanLog(`ZBar video detect error: ${e.message}`); } } // 3) ZBar on enhanced crop variants — every 3 frames if (_detectorZbar && frameCount % 3 === 0) { const crop = _buildScanCropFrame(videoEl, frameCount); if (crop) { try { const codes = await _detectorZbar.detect(crop); if (codes && codes.length > 0) { feedback('detecting'); if (_tryConfirmBarcode(codes[0].rawValue, codes[0].format, 'ZBar+')) return; } } catch (_) {} } } // 4) Local digit OCR (numbers under barcode) — after delay, works offline const elapsed = Date.now() - startTime; if (elapsed > _LOCAL_OCR_DELAY_MS && !digitOcrBusy && (Date.now() - lastDigitOcrAt) > 1800) { lastDigitOcrAt = Date.now(); digitOcrBusy = true; _tryLocalEanDigitOcr(videoEl).finally(() => { digitOcrBusy = false; }); } feedback('scanning'); if (frameCount % 60 === 0) scanLog(`Scanning f${frameCount} (${Math.round(elapsed / 1000)}s)`); requestAnimationFrame(loop); } requestAnimationFrame(loop); } // ===== QUAGGA LEGACY SCANNER (only when native + ZBar unavailable) ===== function startQuaggaScanner(videoEl, isPrimary = true) { 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(`Fallback scanner 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 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 (scaled down for speed) const scale = 0.75; canvas.width = Math.round(vw * scale); canvas.height = Math.round(vh * scale); ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height); } else { // Center crop: 70% wide, 40% tall — 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 for front cam, offline, or low-light conditions if (frontCam || _offlineMode || !navigator.onLine) { enhanceCanvasForBarcode(ctx, canvas.width, canvas.height); } return canvas.toDataURL('image/jpeg', 0.85); } const offlineBoost = _offlineMode || !navigator.onLine; const frameInterval = offlineBoost ? 40 : 60; function scanFrame() { if (!scanning || !scannerStream) return; if (_scannerPaused) { setTimeout(scanFrame, frameInterval); return; } frameCount++; scanPass = (scanPass + 1) % 2; const dataUrl = getFrameDataUrl(scanPass); if (frameCount === 1) { scanLog(`Frame #1 — video: ${videoEl.videoWidth}x${videoEl.videoHeight}`); updateScannerFeedback('scanning'); if (isPrimary) _setScanStatus(t('scan.status_scanning'), '', 'Quagga'); } let callbackCalled = false; const safetyTimer = setTimeout(() => { if (!callbackCalled && scanning) { scanLog(`Quagga timeout on f${frameCount}, retrying...`); setTimeout(scanFrame, 50); } }, 2000); try { const imgSize = Math.max(canvas.width, canvas.height); Quagga.decodeSingle({ src: dataUrl, numOfWorkers: 0, inputStream: { size: Math.min(imgSize, 640) }, decoder: { readers: [ 'ean_reader', 'ean_8_reader', 'upc_reader', 'upc_e_reader', 'code_128_reader', 'code_39_reader' ], multiple: false }, locate: true, locator: { patchSize: offlineBoost ? 'large' : 'medium', halfSample: !offlineBoost } }, 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]; const passName2 = ['full','crop'][scanPass]; // EAN/UPC: confirm on first hit (checksum validated) const highConf = ['ean_reader','ean_8_reader','upc_reader','upc_e_reader'].includes(format); if (highConf || detectCount >= 2 || dominated.count >= 2) { // Validate EAN/UPC checksum — Quagga can occasionally return false positives if (highConf && !validateEANChecksum(code)) { _invalidBarcodeCount++; scanLog(`Invalid EAN checksum: ${code} (retry #${_invalidBarcodeCount})`); _setScanStatus(t('scan.status_invalid').replace('{code}', code), 'invalid', 'Quagga'); lastDetected = ''; detectCount = 0; // reset confidence if (scanning) setTimeout(scanFrame, frameInterval); return; } scanning = false; quaggaRunning = false; updateScannerFeedback(null); scanLog(`CONFIRMED: ${code} [${passName2}] f${frameCount} consec:${detectCount} total:${dominated.count}`); _hideScanLiveCode(); _setScanStatus(t('scan.status_confirmed'), 'confirmed'); onBarcodeDetected(code); return; } _showScanLiveCode(code); _setScanStatus(t('scan.status_partial').replace('{code}', code), 'partial'); } else { updateScannerFeedback('scanning'); } if (scanning) { if (frameCount % 20 === 0) { scanLog(`Scanning... f${frameCount}, partials: ${partialCount}`); } setTimeout(scanFrame, frameInterval); } }); } catch (e) { callbackCalled = true; clearTimeout(safetyTimer); scanLog(`Quagga error: ${e.message}`); if (scanning) setTimeout(scanFrame, offlineBoost ? 200 : 500); } } setTimeout(scanFrame, offlineBoost ? 80 : 200); } // 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() { _scannerPaused = false; quaggaRunning = false; _scanZoomLevel = 2; // always 2x on next start _torchActive = false; const aiBtn = document.getElementById('scan-ai-manual-btn'); if (aiBtn) aiBtn.style.display = 'none'; if (scannerStream) { scannerStream.getTracks().forEach(t => t.stop()); scannerStream = null; } const video = document.getElementById('scanner-video'); if (video) { video.srcObject = null; video.style.transform = ''; } // Reset torch button const tb = document.getElementById('scan-torch-btn'); if (tb) tb.classList.remove('torch-on'); // Hide live code _hideScanLiveCode(); _clearAiMatchPanel(); // 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; } const _barcodeSessionCache = new Map(); function _barcodeCacheKey(barcode) { return String(barcode || '').replace(/\D/g, ''); } /** Fix unit/qty from stored notes; fire-and-forget DB update when needed. */ function _applyLocalBarcodeProductFixes(product) { if (!product) return; if (product.unit === 'pz' && product.default_quantity === 0 && product.notes) { const pesoMatch = product.notes.match(/Peso:\s*([^·]+)/); if (pesoMatch) { const weightStr = pesoMatch[1].trim(); const detected = detectUnitAndQuantity(weightStr); if (detected.unit !== 'pz') { product.unit = detected.unit; product.default_quantity = detected.quantity; product.weight_info = weightStr; if (detected.packageUnit) product.package_unit = detected.packageUnit; if (detected.confCount) product._confCount = detected.confCount; api('product_save', {}, 'POST', { id: product.id, barcode: product.barcode, name: product.name, brand: product.brand || '', category: product.category || '', image_url: product.image_url || '', unit: detected.unit, default_quantity: detected.quantity, package_unit: detected.packageUnit || '', notes: product.notes, }).catch(() => {}); } } } if (!product.weight_info && product.notes) { const pesoMatch = product.notes.match(/Peso:\s*([^·]+)/); if (pesoMatch) product.weight_info = pesoMatch[1].trim(); } if (product.weight_info && product.unit === 'conf' && !product._confCount) { const detected = detectUnitAndQuantity(product.weight_info); if (detected.confCount) product._confCount = detected.confCount; } } function _externalBarcodeNotes(p) { const notesParts = []; if (p.quantity_info) notesParts.push(`${t('product.weight_label')}: ${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(`${t('product.origin_label')}: ${p.origin}`); if (p.labels) notesParts.push(`${t('product.labels_label')}: ${p.labels}`); return notesParts.join(' · '); } function _currentProductFromExternal(p, barcode, saveId) { const detected = detectUnitAndQuantity(p.quantity_info); return { id: saveId, 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 || '', }; } function _finishBarcodeResolved(barcode) { showLoading(false); addToScanRecents(currentProduct); _showScanConfirm(currentProduct.name); stopScanner(); const delay = _spesaMode ? 120 : 300; const next = _spesaMode ? showAddForm : showProductAction; setTimeout(() => next(), delay); } async function _resolveBarcodeLookup(barcode) { const key = _barcodeCacheKey(barcode); if (_barcodeSessionCache.has(key)) { return _barcodeSessionCache.get(key); } const result = await api('resolve_barcode', { barcode: key }); _barcodeSessionCache.set(key, result); return result; } async function _handleBarcodeResolve(result, barcode) { const code = _barcodeCacheKey(barcode); if (!result?.found) return false; if (result.source === 'local' && result.product) { currentProduct = result.product; _applyLocalBarcodeProductFixes(currentProduct); _finishBarcodeResolved(code); return true; } if (result.product) { const p = result.product; const detected = detectUnitAndQuantity(p.quantity_info); const saveResult = await api('product_save', {}, 'POST', { barcode: code, 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: _externalBarcodeNotes(p), }); if (saveResult.id) { currentProduct = _currentProductFromExternal(p, code, saveResult.id); _finishBarcodeResolved(code); return true; } } return false; } async function onBarcodeDetected(barcode) { _dismissFamilySiblingPrompt(); _resetAiFallbackForNewScan(); showLoading(true); if (navigator.vibrate) navigator.vibrate(100); try { const code = _barcodeCacheKey(barcode); const result = await _resolveBarcodeLookup(code); if (await _handleBarcodeResolve(result, code)) return; showLoading(false); showToast(t('error.not_found_manual'), 'error'); _setScanStatus(t('scan.status_scanning'), '', ''); resumeScanner(); } catch (err) { showLoading(false); console.error('Barcode lookup error:', err); showToast(t('error.search'), 'error'); resumeScanner(); } } function submitManualBarcode() { const input = document.getElementById('manual-barcode-input'); autoSubmitEAN(input, true); } // Auto-submit when user finishes typing a valid EAN-13 or EAN-8 function autoSubmitEAN(inputEl, force = false) { const raw = (inputEl.value || '').replace(/\D/g, ''); inputEl.value = raw; // strip non-digits live if (!raw) return; const isComplete = raw.length === 13 || raw.length === 8; const isValid = isComplete && validateEANChecksum(raw); if (isValid) { // Auto-submit on valid EAN stopScanner(); onBarcodeDetected(raw); return; } if (force) { if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; } if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; } if (isComplete && !isValid) { showToast(t('error.barcode_checksum'), 'error'); inputEl.focus(); return; } stopScanner(); onBarcodeDetected(raw); } } // ===== 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; } _dismissFamilySiblingPrompt(); 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 = ` ${catIcon}
${escapeHtml(p.name)}
${p.brand ? escapeHtml(p.brand) + ' · ' : ''}${p.barcode ? '📊 ' + p.barcode : t('product.no_barcode')}
`; 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 = `
${t('scan.create_named').replace('{name}', '"' + escapeHtml(searchName) + '"')}
${t('scan.new_without_barcode')}
`; 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(t('toast.product_created'), '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 = ''; updateSubcategoryField('pf-subcategory', 'pf-subcategory-group', ''); 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 = t('product.title_new'); 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'); // Met à jour les options/visibilité de la sous-catégorie pour la nouvelle catégorie updateSubcategoryField('pf-subcategory', 'pf-subcategory-group', cat); // 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 const unitManuallySet = unitSelect.dataset.manuallySet === 'true'; if (qtyInput.dataset.manuallySet !== 'true' && !unitManuallySet) { unitSelect.value = catDefaults[cat].unit; qtyInput.value = catDefaults[cat].qty; } else if (qtyInput.dataset.manuallySet !== 'true' && unitManuallySet) { qtyInput.value = catDefaults[cat].qty; } } } function onPfUnitChange() { const unitEl = document.getElementById('pf-unit'); if (unitEl) unitEl.dataset.manuallySet = 'true'; const unit = unitEl?.value; const confRow = document.getElementById('pf-conf-size-row'); if (confRow) confRow.style.display = unit === 'conf' ? 'block' : 'none'; } function _customUnitOptionsHtml(selectedKey) { return CUSTOM_UNITS.map(u => ``).join(''); } function resolveUnitSelection(selectId) { const val = document.getElementById(selectId)?.value || 'pz'; const cu = CUSTOM_UNITS.find(u => u.key === val); if (cu) return { realUnit: cu.base_unit, factor: cu.factor, displayUnitKey: cu.key }; return { realUnit: val, factor: 1, displayUnitKey: null }; } function _displayQtyForItem(item) { const cu = item && item.display_unit_key ? CUSTOM_UNITS.find(u => u.key === item.display_unit_key) : null; return cu ? (parseFloat(item.quantity) / cu.factor) : item.quantity; } function _updateBarcodeHint(inputId = 'pf-barcode', hintId = 'pf-barcode-hint') { const hint = document.getElementById(hintId); const val = (document.getElementById(inputId)?.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(targetId = 'pf-barcode', hintId = 'pf-barcode-hint') { 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 = `

${t('scanner.barcode_hint')}

`; 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; const fmt = barcodes[0].format; detectionHistory[code] = (detectionHistory[code] || 0) + 1; // EAN/UPC: confirm immediately (checksum-validated by detector) const highConf = ['ean_13','ean_8','upc_a','upc_e'].includes(fmt); if (highConf || detectionHistory[code] >= 2) { scanning = false; stopStream(); overlayEl.style.display = 'none'; overlayEl.onclick = null; document.getElementById(targetId).value = code; _updateBarcodeHint(targetId, hintId); if (navigator.vibrate) navigator.vibrate(80); showToast(t('scan.barcode_acquired', { 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(); const pfCategory = document.getElementById('pf-category').value; const pfSubcategory = document.getElementById('pf-subcategory')?.value || ''; if (REQUIRED_SUBCATEGORY_CATEGORIES.includes(pfCategory) && !pfSubcategory) { showToast('Merci de préciser la sous-catégorie', 'error'); document.getElementById('pf-subcategory').focus(); return; } 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: pfCategory, subcategory: pfSubcategory || null, 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(t('toast.product_saved'), '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 += `
⚖️ ${escapeHtml(currentProduct.weight_info)}
`; } // Nutriscore badge if (currentProduct.nutriscore) { const ns = currentProduct.nutriscore.toLowerCase(); const nsColor = nutriscoreColors[ns] || '#999'; detailsHtml += `
Nutri-Score ${ns.toUpperCase()}
`; } // 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 += `
🏭 NOVA ${currentProduct.nova_group}${novaLabels[currentProduct.nova_group] ? ' - ' + novaLabels[currentProduct.nova_group] : ''}
`; } // Ecoscore if (currentProduct.ecoscore) { const es = currentProduct.ecoscore.toLowerCase(); const esColor = nutriscoreColors[es] || '#999'; detailsHtml += `
🌍 Eco-Score ${es.toUpperCase()}
`; } // Origin if (currentProduct.origin) { detailsHtml += `
📍 ${escapeHtml(currentProduct.origin)}
`; } // Labels (bio, DOP, etc.) if (currentProduct.labels) { detailsHtml += `
🏷️ ${escapeHtml(currentProduct.labels)}
`; } // Allergens let allergensHtml = ''; if (currentProduct.allergens) { allergensHtml = `
⚠️ ${escapeHtml(t('product.allergens_label'))} ${escapeHtml(currentProduct.allergens)}
`; } // Ingredients (collapsible) let ingredientsHtml = ''; if (currentProduct.ingredients) { ingredientsHtml = `
${escapeHtml(t('product.ingredients_summary'))}

${escapeHtml(currentProduct.ingredients)}

`; } // Conservation let conservationHtml = ''; if (currentProduct.conservation) { conservationHtml = `
🧊 ${escapeHtml(currentProduct.conservation)}
`; } // Hero card preview (matches page-use style) document.getElementById('action-product-preview').innerHTML = ` ${currentProduct.image_url ? `` : `${catIcon}` }
${escapeHtml(currentProduct.name)}
${currentProduct.brand ? `
${escapeHtml(currentProduct.brand)}
` : ''}
${currentProduct.weight_info ? `⚖️ ${escapeHtml(currentProduct.weight_info)}` : ''} ${currentProduct.barcode ? `📊 ${currentProduct.barcode}` : ''}
`; // 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 currentCatForEdit = mapToLocalCategory(currentProduct.category, currentProduct.name, currentProduct.brand); const categoryOptions = Object.entries(CATEGORY_LABELS).map(([key, label]) => `` ).join(''); const editSubcategoryOptions = getSubcategoryOptionsHtml(currentCatForEdit, currentProduct.subcategory || ''); const editSubcategoryList = SUBCATEGORIES_BY_CATEGORY[currentCatForEdit]; const editSubcategoryVisible = (editSubcategoryList && editSubcategoryList.length > 0) ? 'block' : 'none'; const editSubcategoryRequired = REQUIRED_SUBCATEGORY_CATEGORIES.includes(currentCatForEdit); editInfoEl.innerHTML = `

${isUnknown ? '⚠️ ' + t('product.unknown_product') : '✏️ ' + t('product.edit_info')}

${isUnknown ? `

${escapeHtml(t('edit.unknown_hint'))}

` : ''}
`; 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 = `
${detailsHtml ? `
${detailsHtml}
` : ''} ${allergensHtml} ${ingredientsHtml} ${conservationHtml}
`; extraInfoEl.style.display = 'block'; } else { extraInfoEl.style.display = 'none'; extraInfoEl.innerHTML = ''; } // === CHECK INVENTORY FOR THIS PRODUCT === checkInventoryForProduct(currentProduct.id, currentProduct.name).then(({ items: inventoryItems, related: relatedItems }) => { _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 `
${locInfo.icon} ${locInfo.label}${vacuumIcon}${expiryStr}${qtyStr}${pkgF ? ' ' + pkgF : ''} ✏️
`; }).join(''); const totalStr = formatQuantity(totalQty, unit, defQty, pkgUnit); const totalFrac = formatPackageFraction(totalQty, defQty); statusBar.innerHTML = `
${t('action.have_title')}
${totalStr} ${totalFrac ? `${totalFrac}` : ''}
${invHtml}
`; btnsContainer.className = 'action-buttons-4col'; btnsContainer.innerHTML = ` `; } else { // Product NOT in inventory - show only AGGIUNGI statusBar.style.display = 'none'; btnsContainer.className = 'action-buttons'; btnsContainer.innerHTML = ` `; // Remove catalog-edit link if left over from a previous product const orphan = document.getElementById('catalog-edit-link'); if (orphan) orphan.remove(); } // === RELATED STOCK (same generic family, different product/brand) === const relatedEl = document.getElementById('action-related-stock'); if (relatedEl) { if (relatedItems.length > 0) { // Group by product name+brand and sum quantities const grouped = {}; for (const ri of relatedItems) { const key = ri.product_id; if (!grouped[key]) grouped[key] = { item: ri, qty: 0 }; grouped[key].qty += parseFloat(ri.quantity) || 0; } const parts = Object.values(grouped).map(({ item, qty }) => { const qtyStr = formatQuantity(qty, item.unit, item.default_quantity, item.package_unit); const locIcon = (LOCATIONS[item.location] || { icon: '📦' }).icon; const label = item.name + (item.brand ? ` (${item.brand})` : ''); return `${escapeHtml(label)}: ${qtyStr} ${locIcon}`; }).join(''); relatedEl.innerHTML = ``; relatedEl.style.display = 'block'; } else { relatedEl.style.display = 'none'; relatedEl.innerHTML = ''; } } }); // Update back button: go back to shopping if came from shopping list scan const backBtn = document.getElementById('action-back-btn'); if (backBtn) backBtn.onclick = () => goBack(); // 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 = `
🛒 ${t('shopping.scan_target_label')} ${escapeHtml(targetName)}
`; } else if (banner) { banner.style.display = 'none'; } showPage('action'); } // Check if product exists in inventory async function checkInventoryForProduct(productId, productName) { try { const data = await api('inventory_list'); const all = data.inventory || []; const exact = all.filter(i => i.product_id == productId); // Find inventory items from the same generic family (same shopping_name or first token) const firstToken = (_nameTokens(productName || '')[0] || '').toLowerCase(); const sNameFromExact = exact.length > 0 ? (exact[0].shopping_name || '').toLowerCase() : ''; const matchToken = firstToken || sNameFromExact; const related = matchToken ? all.filter(i => { if (i.product_id == productId) return false; const iFirst = (_nameTokens(i.name || '')[0] || '').toLowerCase(); const iSName = (i.shopping_name || '').toLowerCase(); return iFirst === matchToken || iSName === matchToken || (sNameFromExact && (iFirst === sNameFromExact || iSName === sNameFromExact)); }) : []; return { items: exact, related }; } catch(e) { return { items: [], related: [] }; } } // === OPEN INVENTORY EDIT — picks item or shows location picker === function openInventoryEdit() { const items = _actionInventoryItems; if (!items || items.length === 0) { showToast(t('error.no_inventory_entry') || '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 = `

${t('edit.choose_location_hint')}

${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 ``; }).join('')}
`; 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 = `
${escapeHtml(getUnitDisplayLabel(item.unit || 'pz'))}
${Object.entries(LOCATIONS).map(([k, v]) => ` `).join('')}
${_noExpiryCheckboxHtml('action-edit-expiry', !item.expiry_date && !!_getNoExpiryDismissed()[String(item.product_id)])}
`; document.getElementById('modal-overlay').style.display = 'flex'; _initExpiryManualTracking('action-edit-expiry', item); setQtyInputUnitLabel('action-edit-qty', item.unit || 'pz'); } function onActionEditUnitChange() { const unit = document.getElementById('action-edit-unit').value; setQtyInputUnitLabel('action-edit-qty', unit); 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 { realUnit, factor, displayUnitKey } = resolveUnitSelection('action-edit-unit'); const qty = (parseFloat(document.getElementById('action-edit-qty').value) || 0) * factor; const loc = document.getElementById('action-edit-loc').value; const noExpiryChecked = document.getElementById('action-edit-expiry-no-expiry')?.checked || false; if (noExpiryChecked) _dismissNoExpiry(productId); else _undismissNoExpiry(productId); const expiry = noExpiryChecked ? null : (document.getElementById('action-edit-expiry').value || null); const unit = realUnit; const payload = { id, quantity: qty, location: loc, expiry_date: expiry, unit, product_id: productId, display_unit_key: displayUnitKey, vacuum_sealed: document.getElementById('action-edit-vacuum')?.checked ? 1 : 0, expiry_user_set: _expiryUserSetPayload('action-edit-expiry') }; 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(t('toast.updated'), '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(t('error.not_in_inventory'), '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 `
${locInfo.icon} ${locInfo.label}${formatQuantity(inv.quantity, inv.unit, inv.default_quantity, inv.package_unit)}
`; }).join(''); document.getElementById('modal-content').innerHTML = `
${currentProduct.image_url ? `` : `${CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || '📦'}` }

${escapeHtml(currentProduct.name)}

Disponibile: ${qtyDisplay}

${locOptionsHtml}
${t('use.throw_qty_hint')}
${items.map((inv, idx) => { const locInfo = LOCATIONS[inv.location] || { icon: '📦', label: inv.location }; return ``; }).join('')}
${escapeHtml(getUnitDisplayLabel(items[0]?.unit || 'pz'))}
`; 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 = `

${escapeHtml(msg)}

`; 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 () => { try { const result = await _inventoryWaste({ product_id: currentProduct.id, use_all: true, location: '__all__', }, name); if (result.success) { showToast(t('toast.thrown_away', { name: currentProduct.name }), 'success'); showPage('dashboard'); } else { showToast(result.error || t('error.generic'), 'error'); } } catch(e) { 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(); try { const result = await _inventoryWaste({ product_id: currentProduct.id, quantity: qty, location: loc, }, currentProduct.name); if (result.success) { showToast(t('toast.thrown_away_partial', { qty, unit: currentProduct.unit || 'pz', name: currentProduct.name }), 'success'); showPage('dashboard'); } else { showToast(result.error || t('error.generic'), 'error'); } } catch(e) { 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 || ''; const subcategory = document.getElementById('edit-action-subcategory')?.value || ''; const effectiveCategory = category || currentProduct.category || ''; if (REQUIRED_SUBCATEGORY_CATEGORIES.includes(effectiveCategory) && !subcategory) { showToast('Merci de préciser la sous-catégorie', 'error'); document.getElementById('edit-action-subcategory')?.focus(); return; } const notes = (document.getElementById('edit-action-notes')?.value || '').trim(); const barcode = (document.getElementById('edit-action-barcode')?.value || '').trim(); showLoading(true); try { const result = await api('product_save', {}, 'POST', { id: currentProduct.id, barcode: barcode || null, name: name, brand: brand, category: effectiveCategory, subcategory: subcategory || null, image_url: currentProduct.image_url || '', unit: currentProduct.unit || 'pz', default_quantity: currentProduct.default_quantity || 1, notes: notes, }); showLoading(false); if (result.success) { // Update current product in memory currentProduct.name = name; currentProduct.brand = brand; currentProduct.notes = notes; currentProduct.barcode = barcode || null; if (category) currentProduct.category = category; currentProduct.subcategory = subcategory || null; 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() { renderLocationButtons('location-selector-add', document.getElementById('add-location')?.value || 'dispensa', 'selectLocation'); const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || '📦'; document.getElementById('add-product-preview').innerHTML = ` ${currentProduct.image_url ? `` : `${catIcon}` }

${escapeHtml(currentProduct.name)}

${currentProduct.brand ? escapeHtml(currentProduct.brand) : ''}

${currentProduct.weight_info ? `

${escapeHtml(currentProduct.weight_info)}

` : ''}
`; // 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 = `
${t('add.estimated_expiry')} ${estimateLabel}${expirySuffix} ${formatDate(estimatedDate)}

${t('add.hint_modify')}

${_noExpiryCheckboxHtml('add-expiry')}
`; showPage('add'); updateScaleReadButtons(); _initExpiryManualTracking('add-expiry'); // History first (≥3 samples → average of last 3); AI only if history is insufficient (async () => { let hasHistory = false; if (currentProduct?.id) { hasHistory = await _fetchExpiryHistoryAndUpdate(currentProduct.id); } if (_geminiAvailable && currentProduct && !currentProduct._aiHintFetched && !hasHistory) { _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 (_isExpiryManuallySet('add-expiry')) return; 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 = t('product.from_history'); else if (loc === 'freezer' && isVacuum) suffix = ' ' + t('add.suffix_freezer_vacuum'); else if (loc === 'freezer') suffix = ' (freezer)'; else if (isVacuum) suffix = ' ' + t('add.suffix_vacuum'); 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')} ${newLabel}${suffix}`; if (dateEl) dateEl.textContent = formatDate(newDate); } const _EXPIRY_HISTORY_MIN_SAMPLES = 3; async function _fetchExpiryHistoryAndUpdate(productId) { window._historyExpiryDays = null; window._historyExpiryCount = 0; try { const res = await fetch(`api/index.php?action=expiry_history&product_id=${encodeURIComponent(productId)}`, { headers: { ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}) }, }); const data = await res.json(); const minSamples = data.min_samples || _EXPIRY_HISTORY_MIN_SAMPLES; window._historyExpiryCount = data.count || 0; if (data.avg_days && data.avg_days > 0 && (data.count || 0) >= minSamples) { window._historyExpiryDays = data.avg_days; if (_aiProductHintController) { _aiProductHintController.abort(); _aiProductHintController = null; } document.getElementById('ai-hint-loading')?.remove(); if (!_isExpiryManuallySet('add-expiry')) { 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 = ` ${t('product.history_badge')}`; 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')} ${newLabel}${suffix}`; if (dateEl) dateEl.textContent = formatDate(newDate); } window._addBaseExpiryDays = data.avg_days; return true; } } catch (e) { // silently fall back to rule-based estimate } return false; } // ===== AI PRODUCT HINT: shelf-life + storage suggestion ===== let _aiProductHintController = null; async function _applyAIProductHint() { if (!currentProduct) return; if (window._historyExpiryDays && (window._historyExpiryCount || 0) >= _EXPIRY_HISTORY_MIN_SAMPLES) 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 += ' 🤖…'; } 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 && !_isExpiryManuallySet('add-expiry')) { 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 = ` 🤖 AI`; if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')} ${newLabel}${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(t('ai.conservation_hint', { location: 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(); setQtyInputUnitLabel('add-quantity', unit, true); } 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'; } setQtyInputUnitLabel('add-quantity', unit, true); } 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 = ` ${t('product.history_badge')}`; 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 = `
${t('add.estimated_expiry')} ${estimateLabel}${suffix} ${formatDate(estimatedDate)}

${t('add.hint_modify')}

${_noExpiryCheckboxHtml('add-expiry')} `; // Restore quantity - switching purchase type should NOT change it document.getElementById('add-quantity').value = currentQty; _initExpiryManualTracking('add-expiry'); // 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 = `

${t('product.expiry_hint')}

${_noExpiryCheckboxHtml('add-expiry')}

${t('add.remaining_hint')}

`; _initExpiryManualTracking('add-expiry'); // 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) => `
conf
`).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(); } const _RECENT_ADD_DEDUP_MS = 30000; let _recentInventoryAdds = []; function _pruneRecentInventoryAdds() { const cutoff = Date.now() - _RECENT_ADD_DEDUP_MS; _recentInventoryAdds = _recentInventoryAdds.filter(r => r.ts >= cutoff); } function _findRecentInventoryAdd(productId, location) { _pruneRecentInventoryAdds(); let best = null; for (const r of _recentInventoryAdds) { if (r.productId === productId && r.location === location && (!best || r.ts > best.ts)) { best = r; } } return best; } function _recordRecentInventoryAdd(entry) { _pruneRecentInventoryAdds(); _recentInventoryAdds.push(entry); } function _formatQtyPlain(qty, unit, defaultQty, packageUnit) { return formatQuantity(qty, unit, defaultQty, packageUnit).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); } function _formatRecentAddWhen(ts) { const secs = Math.max(1, Math.round((Date.now() - ts) / 1000)); if (secs < 5) return t('time.just_now'); return t('time.seconds_ago').replace('{n}', String(secs)); } function _confirmRecentDuplicateAdd(recent, productName, addQty, unit, defQty, pkgUnit) { const totalStr = _formatQtyPlain(recent.totalQty, recent.unit, recent.defaultQty, recent.packageUnit); const addStr = _formatQtyPlain(addQty, unit, defQty, pkgUnit); const msg = t('add.duplicate_recent_confirm') .replace('{name}', productName) .replace('{when}', _formatRecentAddWhen(recent.ts)) .replace('{total}', totalStr) .replace('{qty}', addStr); return confirm(msg); } async function submitAdd(e) { e.preventDefault(); const { realUnit: selectedUnit, factor: _addUnitFactor, displayUnitKey: _addDisplayUnitKey } = resolveUnitSelection('add-unit'); const productUnit = currentProduct.unit || 'pz'; if (selectedUnit === 'conf') { const confSize = parseFloat(document.getElementById('add-conf-size')?.value); if (!confSize || confSize <= 0) { showToast(t('product.conf_size_required'), 'error'); document.getElementById('add-conf-size')?.focus(); return; } } const location = document.getElementById('add-location').value; const addQty = (parseFloat(document.getElementById('add-quantity').value) || 1) * _addUnitFactor; const pkgUnit = selectedUnit === 'conf' ? (document.getElementById('add-conf-unit')?.value || null) : null; const pkgSize = selectedUnit === 'conf' ? (parseFloat(document.getElementById('add-conf-size')?.value) || null) : null; const unitForAdd = selectedUnit !== productUnit ? selectedUnit : productUnit; const recent = _findRecentInventoryAdd(currentProduct.id, location); if (recent) { const ok = _confirmRecentDuplicateAdd( recent, currentProduct.name, addQty, unitForAdd, pkgSize || currentProduct.default_quantity || recent.defaultQty, pkgUnit || currentProduct.package_unit || recent.packageUnit ); if (!ok) { if (_spesaMode) showPage('scan'); return; } } showLoading(true); try { const noExpiryChecked = document.getElementById('add-expiry-no-expiry')?.checked || false; if (noExpiryChecked) _dismissNoExpiry(currentProduct.id); const result = await api('inventory_add', {}, 'POST', { product_id: currentProduct.id, quantity: addQty, location: document.getElementById('add-location').value, expiry_date: noExpiryChecked ? null : (document.getElementById('add-expiry').value || null), expiry_user_set: _expiryUserSetPayload('add-expiry'), unit: selectedUnit, display_unit_key: _addDisplayUnitKey, 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) { _recordRecentInventoryAdd({ productId: currentProduct.id, location, qty: addQty, unit: result.unit || unitForAdd, defaultQty: result.default_quantity, packageUnit: result.package_unit, totalQty: result.total_qty, ts: Date.now(), }); // Build quantity info for toast let qtyInfo = ''; if (result.total_qty) { const u = result.unit || 'pz'; const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml', 'conf': t('units.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 (!(await spesaModeAfterAdd(result))) { if (result.removed_from_bring) { _applyShoppingListRemovals(result.removed_names || []); setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500); } else if (shoppingListUUID) { const generic = currentProduct.shopping_name || currentProduct.name; const match = _findSimilarItem(generic, shoppingItems) || _findSimilarItem(currentProduct.name, shoppingItems); api('shopping_remove', {}, 'POST', { name: match?.name || generic, rawName: match?.rawName || '', listUUID: shoppingListUUID, }).then(r => { if (r?.success) { _applyShoppingListRemovals([match?.name || generic, match?.rawName].filter(Boolean)); setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500); } }).catch(() => {}); } showPage('dashboard'); } else if (result.removed_from_bring) { setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500); } // 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 || t('error.generic'), '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(); syncUseQtyUnitBadge(); } function renderUsePreview() { const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct?.category, currentProduct?.name)] || '📦'; const imgHtml = currentProduct?.image_url ? `` : `${catIcon}`; document.getElementById('use-product-preview').innerHTML = ` ${imgHtml}
${escapeHtml(currentProduct?.name || '')}
${currentProduct?.brand ? `
${escapeHtml(currentProduct.brand)}
` : ''}
`; } /** * Fill the hero-card meta row with expiry badge + quantity pill. * Called from loadUseInventoryInfo() once inventory data is available. */ function _updateUseHeroMeta(items) { const metaEl = document.getElementById('use-hero-meta'); if (!metaEl) return; const pills = []; // ── Expiry badge ─────────────────────────────────────────────────── const withExpiry = items.filter(i => i.expiry_date && parseFloat(i.quantity) > 0.01); if (withExpiry.length > 0) { withExpiry.sort((a, b) => new Date(a.expiry_date + 'T12:00:00') - new Date(b.expiry_date + 'T12:00:00')); const soonest = withExpiry[0]; const expDate = new Date(soonest.expiry_date + 'T12:00:00'); const today = new Date(); today.setHours(0,0,0,0); const days = Math.round((expDate - today) / 86400000); const locale = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT'; const dateStr = expDate.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: '2-digit' }); let cls, label; if (days < 0) { cls = 'use-pill-expired'; label = `${t('expiry.badge_expired_ago').replace('{n}', Math.abs(days))} (${dateStr})`; } else if (days === 0){ cls = 'use-pill-soon'; label = t('expiry.badge_today'); } else if (days <= 3) { cls = 'use-pill-soon'; label = `${t('expiry.badge_expiring_short').replace('{n}', days)} (${dateStr})`; } else if (days <= 7) { cls = 'use-pill-warn'; label = `${t('expiry.badge_expiring_short').replace('{n}', days)} (${dateStr})`; } else { cls = 'use-pill-ok'; label = `📅 ${dateStr}`; } pills.push(`${label}`); } // ── Quantity + location count pill ──────────────────────────────── if (items.length > 0) { const totalQty = items.reduce((s, i) => s + parseFloat(i.quantity || 0), 0); const unit = items[0]?.unit; const qtyStr = stripHtml(formatQuantity(totalQty, unit, items[0]?.default_quantity, items[0]?.package_unit)); const locCount = new Set(items.map(i => i.location)).size; const locSuffix = locCount > 1 ? ` · ${locCount} ${t('use.locations_short')}` : ''; pills.push(`📦 ${escapeHtml(qtyStr)}${locSuffix}`); } metaEl.innerHTML = pills.join(''); } // 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})` : ''; if (soonest.opened_at) { // The soonest "expiry" is a calculated date from when the item was opened — show days-open instead const todayBase = new Date(); todayBase.setHours(0, 0, 0, 0); const openedDays = Math.round((todayBase - new Date(soonest.opened_at)) / 86400000); const whenOpenedStr = openedDays <= 0 ? t('expiry.opened_today_long') : t('expiry.opened_ago_long').replace('{n}', openedDays); hintEl.innerHTML = t('use.expiry_warning_opened').replace('{loc}', locLabel).replace('{when}', whenOpenedStr); } else { hintEl.innerHTML = t('use.expiry_warning').replace('{loc}', locLabel).replace('{date}', `${dateStr}`).replace('{when}', whenStr); } hintEl.style.display = 'block'; } function _isOpenedInventoryItem(item) { if (item.opened_at) return true; 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 _useAllTotalQty(items) { return items.reduce((s, i) => s + parseFloat(i.quantity || 0), 0); } 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; } // ── Hero card meta: expiry badge + qty pill ────────────────── _updateUseHeroMeta(items); // ── 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'); // Hide the location row when the product is in only one location (nothing to choose) const locGroup = document.getElementById('use-location-group'); if (locGroup) locGroup.style.display = productLocations.length > 1 ? '' : 'none'; // 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) ? ` 🔓 ${t('use.opened_badge')}` : ''; return ``; }).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 = `
${locInfo.icon} ${locInfo.label}
`; } 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 = `${t('use.available')} ` + 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; // If scale is active, prefer grams for weighing — unless user explicitly chose conf. const _scaleActiveNow = getSettings().scale_enabled && getSettings().scale_gateway_url && _scaleConnected; const _pref = _useConfMode._userPref; switchUseUnit(_pref === 'conf' ? 'conf' : (_scaleActiveNow ? 'sub' : 'conf')); // Fraction shortcut buttons for conf mode (½, 1, 2 packages) const existingConfFrac = document.getElementById('conf-fraction-btns'); if (existingConfFrac) existingConfFrac.remove(); const confFracDiv = document.createElement('div'); confFracDiv.id = 'conf-fraction-btns'; confFracDiv.className = 'pz-fraction-btns'; const maxConf = Math.min(4, Math.ceil(_useConfMode.totalConf)); const confFracs = [0.25, 0.5, 1]; if (maxConf >= 2) confFracs.push(2); confFracDiv.innerHTML = `
${ confFracs.filter(f => f <= _useConfMode.totalConf + 0.01).map(f => { const label = f === 0.25 ? '¼' : f === 0.5 ? '½' : f; return ``; }).join('') }
`; document.querySelector('#page-use .use-partial').appendChild(confFracDiv); // Trigger a live-box refresh with the latest reading if on scale if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight); syncUseQtyUnitBadge(); } 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 = `${t('use.available')} ` + 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 = `

${t('use.partial_piece_hint')}

`; document.querySelector('#page-use .use-partial').appendChild(fracDiv); } syncUseQtyUnitBadge(); } } 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 (_useConfMode) _useConfMode._userPref = mode; // Show/hide fraction buttons depending on mode const confFracBtns = document.getElementById('conf-fraction-btns'); const pzFracBtns = document.getElementById('pz-fraction-btns'); 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}` }); if (confFracBtns) confFracBtns.style.display = 'none'; } else { confBtn.classList.add('active'); subBtn.classList.remove('active'); _useConfMode._activeUnit = 'conf'; qtyInput.value = Math.min(1, _useConfMode.totalConf); // start at 1 or max if < 1 qtyInput.step = 'any'; qtyInput.min = 0.25; hint.textContent = t('recipes.packs_of_have', { size: `${_useConfMode.packageSize}${_useConfMode.subLabel}`, count: _useConfMode.totalConf.toFixed(1) }); if (confFracBtns) confFracBtns.style.display = ''; } syncUseQtyUnitBadge(); } function setConfFraction(f) { const input = document.getElementById('use-quantity'); if (!input) return; input.value = Math.min(f, _useConfMode?.totalConf ?? f); document.querySelectorAll('#conf-fraction-btns .frac-btn').forEach(b => b.classList.toggle('active', parseFloat(b.dataset.frac) === f) ); } 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_NEEDED = 1; // choices needed to confirm a preference // ── PREFERRED MOVE-AFTER-USE LOCATION ──────────────────────────────────── // Tracks where the user puts the remainder after using a product. // After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely. const _PREF_MOVE_NEEDED = 1; let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal function _getMoveLocHistory(productId, fromLoc) { const all = _prefMoveLocCache || {}; return all[`${productId}|${fromLoc}`] || []; } function _recordMoveLocChoice(productId, fromLoc, toLoc) { const all = Object.assign({}, _prefMoveLocCache || {}); const key = `${productId}|${fromLoc}`; const hist = (all[key] || []).slice(); hist.push(toLoc); if (hist.length > 8) hist.splice(0, hist.length - 8); all[key] = hist; _prefMoveLocCache = all; _saveToServer('pref_move_loc', all); } function _getPreferredMoveLoc(productId, fromLoc) { const hist = _getMoveLocHistory(productId, fromLoc); if (hist.length < _PREF_MOVE_NEEDED) return null; const recent = hist.slice(-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_MOVE_NEEDED ? topLoc : null; } function _getPrefLocHistory(productId) { const all = _prefUseLocCache || {}; return all[String(productId)] || []; } function _recordUseLocationChoice(productId, loc) { const all = Object.assign({}, _prefUseLocCache || {}); const key = String(productId); const hist = (all[key] || []).slice(); hist.push(loc); if (hist.length > 8) hist.splice(0, hist.length - 8); all[key] = hist; _prefUseLocCache = all; _saveToServer('pref_use_loc', all); } 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 < 0.25; // warn when less than 25% of a package remains // 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 || (sd.shopping_name || '').toLowerCase() === bLower ); if (exact) return exact; const bTokens = _nameTokens(bringName); if (bTokens.length === 0) return null; const bFirst = bTokens[0]; const firstMatch = smartItems.find(sd => { const groupName = (sd.shopping_name || sd.name).toLowerCase(); if (groupName === bLower) return true; const sdTokens = _nameTokens(sd.shopping_name || 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; } /** * Show a small auto-dismissing bottom bar asking the user if the opened product * was put under vacuum seal. Auto-confirms after DURATION ms with the default value * (if it was already vacuum sealed → default yes, otherwise → default no). * @param {number} openedId - inventory row ID of the opened item * @param {number|boolean} wasVacuumSealed - previous vacuum_sealed state (0/1) */ function _showVacuumPrompt(openedId, wasVacuumSealed) { const DURATION = 8000; const defaultYes = !!wasVacuumSealed; const old = document.getElementById('_vacuum-prompt'); if (old) old.remove(); const bar = document.createElement('div'); bar.id = '_vacuum-prompt'; bar.style.cssText = [ 'position:fixed', 'bottom:80px', 'left:50%', 'transform:translateX(-50%)', 'z-index:9999', 'background:#1e293b', 'color:#fff', 'border-radius:14px', 'padding:12px 16px', 'display:flex', 'align-items:center', 'gap:10px', 'box-shadow:0 4px 24px rgba(0,0,0,0.5)', 'max-width:360px', 'width:calc(100% - 32px)', 'box-sizing:border-box', 'overflow:hidden' ].join(';'); bar.innerHTML = ` ${t('add.vacuum_question')}
`; document.body.appendChild(bar); let dismissed = false; let rafH = null; let timerH = null; function dismiss(vacuum) { if (dismissed) return; dismissed = true; if (timerH) clearTimeout(timerH); if (rafH) cancelAnimationFrame(rafH); bar.remove(); api('inventory_update', {}, 'POST', { id: openedId, vacuum_sealed: vacuum ? 1 : 0 }) .then(() => { if (vacuum) showToast(t('add.vacuum_saved'), 'success'); }) .catch(() => {}); } bar.querySelector('#_vac-yes').addEventListener('click', () => dismiss(true)); bar.querySelector('#_vac-no').addEventListener('click', () => dismiss(false)); const barEl = bar.querySelector('#_vac-bar'); const start = performance.now(); function tick() { if (dismissed) return; const pct = Math.min(100, (performance.now() - start) / DURATION * 100); if (barEl) barEl.style.width = (100 - pct) + '%'; if (pct < 100) rafH = requestAnimationFrame(tick); } rafH = requestAnimationFrame(tick); timerH = setTimeout(() => dismiss(defaultYes), DURATION); } 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; // If the backend provided a family-wide total (all products sharing the same // shopping_name and unit, e.g. "Uova Sfoglia Gialla" + "Uova biologiche"), // use that for the low-stock check so that a second scanned package of eggs // prevents a false "running out" warning. const familyTotal = (result.total_family_remaining !== undefined) ? result.total_family_remaining : totalRemaining; // ── 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 + 🛒 marker in spec so cron cleanup can auto-remove. const spec = (shoppingName !== name ? name + (result.product_brand ? ` · ${result.product_brand}` : '') : name) + ' · 🛒 Esaurito'; (async () => { try { const payload = { items: [{ name: shoppingName, specification: spec }] }; if (shoppingListUUID) payload.listUUID = shoppingListUUID; const data = await api('shopping_add', {}, 'POST', payload); if (data.success && data.added > 0) { showToast(t('toast.finished_to_bring'), 'info'); } } catch(_e) { /* silent */ } })(); } if (afterCallback) afterCallback(); return; } if (!isLowStock(familyTotal, 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 = `
${_smartMsg}
`; } // _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 = `

${t('lowstock.message').replace('{name}', `${escapeHtml(name)}`).replace('{qty}', `${remainLabel}`)}

${smartNote}

${t('lowstock.question')}

`; 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('shopping_add', {}, 'POST', payload); if (data.success && data.added > 0) { // Pin as user-added so cleanup never auto-removes it const pinned = Object.assign({}, _pinnedBringCache || {}); pinned[bringName.toLowerCase()] = Date.now(); _pinnedBringCache = pinned; _saveToServer('pinned_bring', pinned); 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; let _moveModalTouchHandler = null; function clearMoveModalTimer() { if (_moveModalTimer) { clearTimeout(_moveModalTimer); _moveModalTimer = null; } if (_moveModalRAF) { cancelAnimationFrame(_moveModalRAF); _moveModalRAF = null; } if (_moveModalTouchHandler) { document.getElementById('modal-content')?.removeEventListener('pointerdown', _moveModalTouchHandler, true); _moveModalTouchHandler = null; } } function startMoveModalCountdown(btnId, onExpire) { clearMoveModalTimer(); // Any touch inside the modal cancels the auto-close countdown _moveModalTouchHandler = () => clearMoveModalTimer(); document.getElementById('modal-content')?.addEventListener('pointerdown', _moveModalTouchHandler, { capture: true, once: true }); 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, openedVacuumSealed, unit) { // Store context so _saveVacuumAndStay can record the choice _pendingMoveCtx = { productId: product.id, fromLoc, openedId }; // If a preference is established, skip the modal entirely and auto-apply const prefMoveLoc = _getPreferredMoveLoc(product.id, fromLoc); if (prefMoveLoc) { if (prefMoveLoc === fromLoc) { // Preference: stay in place — silent, no modal _saveVacuumAndStay(openedId || 0); } else { // Preference: move to another location — apply silently confirmMoveAfterUse(product.id, fromLoc, prefMoveLoc, openedId || 0, !!(openedVacuumSealed ?? product.vacuum_sealed)); } return; } const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc); const locButtons = otherLocs.map(([k, v]) => `` ).join(''); // Show vacuum checkbox for any container-type unit or if the item was previously vacuum sealed. // Pre-checked when it was already sealed (semi-automatic: if you sealed it last time, you likely will again). const wasVacuum = !!(openedVacuumSealed ?? product.vacuum_sealed); // Always offer vacuum sealing: any leftover food can be vacuum sealed regardless of unit type. const vacuumRow = ` `; document.getElementById('modal-content').innerHTML = `

${t('move.question').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest')).replace('{name}', `${escapeHtml(product.name)}`)}

${locButtons}
${vacuumRow}
`; document.getElementById('modal-overlay').style.display = 'flex'; startMoveModalCountdown('btn-move-stay', () => { _saveVacuumAndStay(openedId || 0); }); } /** Save vacuum state when user chooses to keep the item at the current location. */ async function _saveVacuumAndStay(openedId) { // Record the "stay" preference before closing if (_pendingMoveCtx) { _recordMoveLocChoice(_pendingMoveCtx.productId, _pendingMoveCtx.fromLoc, _pendingMoveCtx.fromLoc); _pendingMoveCtx = null; } closeModal(); if (openedId) { const isVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0; try { await api('inventory_update', {}, 'POST', { id: openedId, vacuum_sealed: isVacuum }); if (isVacuum) showToast(t('add.vacuum_saved'), 'success'); } catch (_) {} } showPage('dashboard'); } async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId, forcedVacuum) { clearMoveModalTimer(); const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0); // Record preference if (_pendingMoveCtx && _pendingMoveCtx.productId === productId) { _recordMoveLocChoice(productId, fromLoc, toLoc); _pendingMoveCtx = null; } closeModal(); showLoading(true); try { const invData = await api('inventory_list'); const invRows = invData.inventory || []; if (openedId) { const item = invRows.find(i => i.id == openedId); const product = { name: currentProduct?.name || item?.name || '', category: currentProduct?.category || item?.category || '' }; const payload = { id: openedId, location: toLoc, product_id: productId, vacuum_sealed: newVacuum, }; if (!item?.expiry_user_set) { payload.expiry_date = addDays(estimateOpenedExpiryDays(product, toLoc)); } await api('inventory_update', {}, 'POST', payload); showToast(t('move.moved_toast').replace('{location}', LOCATIONS[toLoc]?.label || toLoc), 'success'); } else { const item = invRows.find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0); if (item) { const product = { name: item.name || '', category: item.category || '' }; const payload = { id: item.id, location: toLoc, product_id: productId, vacuum_sealed: newVacuum, }; if (!item.expiry_user_set) { let days = estimateExpiryDays(product, toLoc); if (newVacuum) days = getVacuumExpiryDays(days); payload.expiry_date = addDays(days); } await api('inventory_update', {}, 'POST', payload); showToast(t('move.moved_simple', { location: LOCATIONS[toLoc]?.label || toLoc }), 'success'); } } } catch (e) { console.error('Move error:', e); } showLoading(false); showPage('dashboard'); } async function submitUseAll() { const name = currentProduct ? currentProduct.name : ''; const items0 = _useCurrentItems ? _useCurrentItems.filter(i => parseFloat(i.quantity) > 0) : []; if (items0.length === 0) return; const unit = items0[0]?.unit || 'pz'; const totalQty = _useAllTotalQty(items0); const isConfMulti = unit === 'conf' && totalQty > 1.001; // Opened packages and/or conf disambiguation (one conf vs all) const allOpened = items0.filter(_isOpenedInventoryItem); if (allOpened.length >= 1 || isConfMulti) { _showUseAllDisambiguation(allOpened, items0, { showOneConf: isConfMulti }); return; } // No opened packages → if there is only one item row, it's unambiguous — skip confirm if (items0.length === 1) { _doSubmitUseAll(); return; } // Multiple rows, no opened packages → standard destructive confirm const qtyStr = stripHtml(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() { // Called only when there are no opened packages (submitUseAll already handles disambiguation) showLoading(true); try { const result = await api('inventory_use', {}, 'POST', { product_id: currentProduct.id, use_all: true, location: '__all__', }); showLoading(false); if (result.success) { showToast(t('toast.finished_all', { name: currentProduct.name }), 'success'); if (result.added_to_bring) { setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500); } showLowStockBringPrompt(result, () => showPage('dashboard')); } else { showToast(result.error || t('error.generic'), 'error'); } } catch (err) { showLoading(false); showToast(t('error.connection'), 'error'); } } /** * Ask whether the user finished one package or everything. * Shown for opened packages and/or conf products with qty > 1. */ function _showUseAllDisambiguation(openedItems, allItems, options = {}) { const { showOneConf = false } = options; const contentEl = document.getElementById('modal-content'); const preferLoc = document.getElementById('use-location')?.value || openedItems[0]?.location || allItems[0]?.location || 'dispensa'; const locButtons = openedItems.map(item => { const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location }; const qtyStr = stripHtml(formatQuantity(parseFloat(item.quantity), item.unit, item.default_quantity, item.package_unit)); return ``; }).join(''); const totalQty = _useAllTotalQty(allItems); const unit = allItems[0]?.unit || 'pz'; const totalStr = stripHtml(formatQuantity(totalQty, unit, allItems[0]?.default_quantity, allItems[0]?.package_unit)); const oneStr = stripHtml(formatQuantity(1, unit, allItems[0]?.default_quantity, allItems[0]?.package_unit)); const oneConfBtn = showOneConf ? ` ` : ''; contentEl.innerHTML = `

${t('use.disambiguation_hint')}

${locButtons} ${oneConfBtn} `; document.getElementById('modal-overlay').style.display = 'flex'; } async function _submitUseOneConf(location) { showLoading(true); try { const result = await api('inventory_use', {}, 'POST', { product_id: currentProduct.id, quantity: 1, location, use_all: false, }); showLoading(false); if (result.success) { showToast(t('use.toast_one_conf_finished').replace('{name}', currentProduct.name), 'success'); if (result.added_to_bring) { setTimeout(() => showToast(t('use.toast_bring'), 'info'), 1500); } _recordUseLocationChoice(currentProduct.id, location); const _vacUnit = result.product_unit || currentProduct?.unit || ''; const moveCallback = result.remaining > 0 ? () => showMoveAfterUseModal(currentProduct, location, result.remaining, result.opened_id, result.opened_vacuum_sealed ?? 0, _vacUnit) : () => showPage('dashboard'); showLowStockBringPrompt(result, moveCallback); } else if (!result.duplicate) { showToast(result.error || t('error.generic'), 'error'); } } catch (err) { showLoading(false); showToast(t('error.connection'), 'error'); } } function _confirmThenSubmitUseAllAt(location, isOpenedOnly) { const name = currentProduct ? currentProduct.name : ''; const items = _useCurrentItems ? _useCurrentItems.filter(i => parseFloat(i.quantity) > 0) : []; if (isOpenedOnly) { // Finisce solo la confezione aperta — azione leggera, nessun confirm necessario _submitUseAllAt(location, true); return; } // Finisce tutto — richiede confirm distruttivo const totalQty = items.reduce((s, i) => s + parseFloat(i.quantity || 0), 0); const unit = items[0]?.unit || 'pz'; const qtyStr = stripHtml(formatQuantity(totalQty, unit, items[0]?.default_quantity, items[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})`, () => _submitUseAllAt('__all__', false), t('use.use_all_confirm_btn') || '✅ Sì, finito' ); } 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 || t('error.generic'), '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(t('toast.finished_to_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 _vacUnit = result.product_unit || currentProduct?.unit || ''; const moveCallback = result.remaining > 0 ? () => showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id, result.opened_vacuum_sealed ?? 0, _vacUnit) : () => showPage('dashboard'); // Check low stock → Bring! prompt, then move/vacuum modal showLowStockBringPrompt(result, moveCallback); } else if (result.duplicate) { // Silently ignore: this was a scale double-trigger, not a real error } else { showToast(result.error || t('error.generic'), '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(t('error.camera'), '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 = `

${t('scanner.ai_identifying')}

`; 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 = `

${t('ai.no_api_key').replace(/\n/g, '
')}

`; } else if (/resource.?exhaust|quota|rate.?limit/i.test(result.error || '')) { resultDiv.innerHTML = `

⏳ ${t('error.ai_quota')}

`; } else { resultDiv.innerHTML = `

❌ ${escapeHtml(result.error || t('error.identification'))}

`; } 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 = `

🤖 Prodotto identificato

`; html += `
`; html += `${escapeHtml(id.name)}`; if (id.brand) html += ` - ${escapeHtml(id.brand)}`; if (id.description) html += `

${escapeHtml(id.description)}

`; html += `
`; // Show existing local products first if (localMatches.length > 0) { html += `

${t('product.already_in_pantry')}

`; html += `
`; localMatches.forEach((p, idx) => { html += `
`; if (p.image_url) { html += ``; } html += `
`; html += `${escapeHtml(p.name)}`; if (p.brand) html += `
${escapeHtml(p.brand)}`; if (p.default_quantity && p.unit) html += `
${p.default_quantity} ${p.unit}`; html += `
`; if (p.barcode) html += `${p.barcode}`; html += `
`; }); html += `
`; } if (matches.length > 0) { html += `

📦 Prodotti corrispondenti

`; html += `
`; matches.forEach((m, idx) => { html += `
`; if (m.image_url) { html += ``; } html += `
`; html += `${escapeHtml(m.name)}`; if (m.brand) html += `
${escapeHtml(m.brand)}`; if (m.quantity_info) html += `
${escapeHtml(m.quantity_info)}`; html += `
`; html += `${m.barcode}`; html += `
`; }); html += `
`; } // Option to save as-is without barcode html += `
`; html += ``; html += `
`; resultDiv.innerHTML = html; // Store data for later use window._aiIdentified = id; window._aiMatches = matches; } catch (err) { console.error('AI identify error:', err); resultDiv.innerHTML = `

❌ ${t('error.connection')}

`; } } 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(`${t('product.weight_label')}: ${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(`${t('product.origin_label')}: ${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(t('toast.product_saved'), '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 = `

${t('scanner.product_label_hint')}

`; 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 = `

${t('error.camera')}

`; } } 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 = `

⏳ ${t('error.ai_quota')}

`; } else { resultEl.innerHTML = `

❌ ${escapeHtml(result.error || t('error.identification'))}

`; } return; } const id = result.identified; const matches = result.off_matches || []; let html = `
${escapeHtml(id.name)}`; if (id.brand) html += ` — ${escapeHtml(id.brand)}`; if (id.description) html += `

${escapeHtml(id.description)}

`; html += `
`; if (matches.length > 0) { html += `

${t('product.select_variant')}

`; html += `
`; matches.forEach((m, idx) => { html += `
`; if (m.image_url) html += ``; html += `
${escapeHtml(m.name)}`; if (m.brand) html += `
${escapeHtml(m.brand)}`; if (m.quantity_info) html += `
${escapeHtml(m.quantity_info)}`; html += `
${escapeHtml(m.barcode)}
`; }); html += `
`; } html += ``; resultEl.innerHTML = html; window._pfAiIdentified = id; window._pfAiMatches = matches; } catch (err) { statusEl.style.display = 'none'; resultEl.style.display = 'block'; resultEl.innerHTML = `

❌ ${t('error.connection')}

`; } } 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(t('ai.fields_filled'), '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 = `
📦

${t('inventory.empty_db')}

`; return; } container.innerHTML = products.map(p => { const catIcon = CATEGORY_ICONS[mapToLocalCategory(p.category, p.name)] || '📦'; return `
${p.image_url ? `` : catIcon}
${escapeHtml(p.name)}
${p.brand ? `
${escapeHtml(p.brand)}
` : ''}
${p.barcode ? `📊 ${p.barcode}` : ''} ${catIcon} ${p.category || 'Non categorizzato'}
`; }).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(t('error.generic'), 'error'); } } // ===== SHOPPING LIST (BRING! INTEGRATION) ===== let shoppingListUUID = ''; let shoppingItems = []; let suggestionItems = []; let _spesaScanTarget = null; // { name, rawName, idx } when tapping item to scan // Inventory cache for "already at home" hints in the shopping list. // Loaded once per shopping page visit and reused for all item hints. let _shoppingInventoryCache = null; async function _getShoppingInventoryCache() { if (_shoppingInventoryCache !== null) return _shoppingInventoryCache; try { const data = await api('inventory_list'); _shoppingInventoryCache = data.inventory || []; } catch(e) { _shoppingInventoryCache = []; } return _shoppingInventoryCache; } // ===== 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 (server-synced) ===== function getShoppingTags(itemName) { const tags = _shoppingTagsCache || {}; return tags[itemName.toLowerCase()] || []; } function toggleShoppingTag(itemIdx, tag) { const item = shoppingItems[itemIdx]; if (!item) return; try { const key = item.name.toLowerCase(); const tags = Object.assign({}, _shoppingTagsCache || {}); const existing = (tags[key] || []).slice(); 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]; _shoppingTagsCache = tags; _saveToServer('shopping_tags', 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('shopping_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) { loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list 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('shopping_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID }); if (r.success) { _markBringPurchased([name]); // prevent background sync from re-adding before barcode scan 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: t('shopping.urgency_spec_medium'), low: t('shopping.urgency_spec_low'), }; const urgLabel = urgencyLabels[urgency] || ''; if (urgLabel && brand) return `${urgLabel} · ${brand}`; if (urgLabel) return urgLabel; return brand || ''; } /** * Track items auto-added by autoAddCriticalItems so the cleanup * function only ever removes those, never manually-added ones. */ function _getAutoAddedBring() { const map = Object.assign({}, _autoAddedBringCache || {}); 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) { _autoAddedBringCache = map; _saveToServer('auto_added_bring', map); } return map; } function _markAutoAddedBring(names) { const map = _getAutoAddedBring(); const now = Date.now(); for (const n of names) map[n.toLowerCase()] = now; _autoAddedBringCache = map; _saveToServer('auto_added_bring', map); } function _unmarkAutoAddedBring(names) { const map = _getAutoAddedBring(); for (const n of names) delete map[n.toLowerCase()]; _autoAddedBringCache = map; _saveToServer('auto_added_bring', map); } // ===== BRING! PURCHASED BLOCKLIST (server-synced) ===== // When an item disappears from Bring (user bought it), we block auto-re-add for 4h. const _BRING_PURCHASED_TTL = 72 * 60 * 60 * 1000; // 72 h — match server blocklist function _getBringPurchasedBlocklist() { const map = Object.assign({}, _bringBlocklistCache || {}); 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) { _bringBlocklistCache = map; _saveToServer('bring_blocklist', map); } return map; } function _markBringPurchased(names) { const map = _getBringPurchasedBlocklist(); const now = Date.now(); for (const n of names) map[n.toLowerCase()] = now; _bringBlocklistCache = map; _saveToServer('bring_blocklist', map); } function _isBringPurchased(name, urgency) { const ttl = _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; }); } /** Drop smart-shopping rows the user already bought (blocklist from spesa). */ function _filterPurchasedSmartItems(items) { if (!Array.isArray(items) || !items.length) return items || []; return items.filter(item => { const names = [item.shopping_name, item.name].filter(Boolean); return !names.some(n => _isBringPurchased(n, item.urgency)); }); } 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())); const toAdd = smartShoppingItems.filter(i => { if (i.on_bring) return false; if (_isBringPurchased(i.name, i.urgency)) return false; return ['critical', 'high', 'medium', 'low'].includes(i.urgency); }); if (toAdd.length === 0) return; const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) })); try { const result = await api('shopping_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); if (result.success && result.added > 0) { // Track these as auto-added so cleanupObsoleteBringItems can safely remove them later _markAutoAddedBring(itemsToAdd.map(i => i.name)); 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 auto-add/cleanup guards so the next run is unconditional. // Do NOT clear _userPinnedBring — items the user manually added must stay protected. _bringBlocklistCache = {}; _saveToServer('bring_blocklist', {}); localStorage.removeItem('_autoAddedCriticalTs'); localStorage.removeItem('_bringCleanupTs'); _autoAddedBringCache = {}; _saveToServer('auto_added_bring', {}); logOperation('force_sync_bring', {}); // Reload everything from scratch await loadShoppingList(); await syncShoppingPriceTotal(true); if (btn) { btn.disabled = false; btn.textContent = `🔄 ${t('shopping.force_sync')}`; } showToast(`🔄 ${t('shopping.sync_done')}`, 'success'); } // ───────────────────────────────────────────────────────────────── // SHOPPING LIST PRICE ESTIMATION // ───────────────────────────────────────────────────────────────── let _pricesFetching = false; /** In-memory price cache: survives list re-renders in the same session */ let _cachedPrices = {}; /** Canonical shopping total — same figure on dashboard, Spesa page and screensaver */ let _canonicalShoppingTotal = null; let _priceTotalFetchPromise = null; function _formatShoppingTotalLabel(total, currency) { const sym = _currencySymbol(currency || getSettings().price_currency || 'EUR'); return `ca. ${sym}${Number(total).toFixed(2)}`; } function _setCanonicalShoppingTotal(total, currency, meta = {}) { if (total == null || isNaN(Number(total))) return; const cur = currency || getSettings().price_currency || 'EUR'; const label = dataTotalLabelOrFormat(meta.totalLabel, total, cur); _canonicalShoppingTotal = { total: Number(total), label, currency: cur, ts: Date.now(), itemCount: meta.itemCount ?? shoppingItems.length, pricedCount: meta.pricedCount ?? 0, }; try { sessionStorage.setItem('_pricetotal', label); sessionStorage.setItem('_pricetotal_ts', String(_canonicalShoppingTotal.ts)); sessionStorage.setItem('_pricetotal_items', String(_canonicalShoppingTotal.itemCount)); } catch { /* quota */ } _applyShoppingTotalDisplay(); } function dataTotalLabelOrFormat(totalLabel, total, currency) { if (totalLabel && typeof totalLabel === 'string') { return totalLabel.startsWith('ca.') ? totalLabel : `ca. ${totalLabel}`; } return _formatShoppingTotalLabel(total, currency); } function _loadCanonicalTotalFromSession() { if (_canonicalShoppingTotal) return; try { const label = sessionStorage.getItem('_pricetotal'); const ts = parseInt(sessionStorage.getItem('_pricetotal_ts') || '0', 10); if (!label || !ts) return; const num = parseFloat(label.replace(/[^\d.,]/g, '').replace(',', '.')); _canonicalShoppingTotal = { total: isNaN(num) ? 0 : num, label, ts, itemCount: parseInt(sessionStorage.getItem('_pricetotal_items') || '0', 10), currency: getSettings().price_currency || 'EUR', pricedCount: 0, }; } catch { /* ignore */ } } /** Push canonical total to dashboard, shopping bar and screensaver. */ function _applyShoppingTotalDisplay() { const s = getSettings(); const dash = document.getElementById('stat-price-total'); const shop = document.getElementById('price-total-value'); if (!s.price_enabled || !_canonicalShoppingTotal) { if (dash) dash.style.display = 'none'; return; } const { label } = _canonicalShoppingTotal; if (dash) { dash.textContent = label; dash.style.display = ''; } if (shop) shop.textContent = label; if (_screensaverActive) updateScreensaverShopping(); } function _clearCanonicalShoppingTotal() { _canonicalShoppingTotal = null; try { sessionStorage.removeItem('_pricetotal'); sessionStorage.removeItem('_pricetotal_ts'); sessionStorage.removeItem('_pricetotal_items'); } catch { /* quota */ } const dash = document.getElementById('stat-price-total'); if (dash) dash.style.display = 'none'; const shop = document.getElementById('price-total-value'); if (shop) shop.textContent = '–'; if (_screensaverActive) updateScreensaverShopping(); } /** * Fetch server-side shopping total and refresh every UI surface. * Ensures shoppingItems is loaded first when needed. */ async function syncShoppingPriceTotal(forceRefresh = false) { const s = getSettings(); if (!s.price_enabled || !_geminiAvailable) return; if (_priceTotalFetchPromise && !forceRefresh) return _priceTotalFetchPromise; _priceTotalFetchPromise = (async () => { if (!shoppingItems.length) { try { const data = await api('shopping_list'); if (data.success) shoppingItems = data.purchase || []; } catch { /* offline */ } } if (!shoppingItems.length) { _clearCanonicalShoppingTotal(); return; } await fetchAllPrices(forceRefresh); })().finally(() => { _priceTotalFetchPromise = null; }); return _priceTotalFetchPromise; } /** * Build the items payload for the price API from the current shoppingItems array. * Tries to parse quantity/unit from the Bring! specification field. */ function _buildPricePayload() { return shoppingItems.map((item) => { const smart = _matchBringToSmart(item.name, smartShoppingItems); if (smart) { const unit = smart.unit || 'conf'; const defQty = parseFloat(smart.default_qty) || 0; const pkgUnit = smart.package_unit || ''; // One shopping-list line ≈ one retail purchase (not 14-day restock qty). if (unit === 'conf' && defQty > 0 && pkgUnit) { return { name: item.name, quantity: defQty, unit: pkgUnit.toLowerCase(), default_quantity: defQty, package_unit: pkgUnit, }; } if (unit === 'pz') { const gramsPerPiece = defQty >= 20 ? defQty : 200; return { name: item.name, quantity: 2, unit: 'pz', default_quantity: gramsPerPiece, package_unit: 'g', }; } if ((unit === 'g' || unit === 'ml') && defQty > 0) { return { name: item.name, quantity: defQty, unit, default_quantity: defQty, package_unit: pkgUnit, }; } } return { name: item.name, quantity: 1, unit: 'conf', default_quantity: 0, package_unit: '' }; }); } /** Format inventory qty for human-readable pantry hints (conf → grams/ml when known). */ function _formatInvQtyDisplay(qty, unit, defaultQty = 0, packageUnit = '') { const q = parseFloat(qty) || 0; if (q <= 0) return ''; const u = unit || 'pz'; const pkg = (packageUnit || '').toLowerCase(); const def = parseFloat(defaultQty) || 0; if (u === 'conf' && def > 0 && (pkg === 'g' || pkg === 'ml')) { return `${Math.round(q * def)} ${pkg}`; } if (u === 'conf' && def > 0) { return `${Math.round(q * 10) / 10} conf (${Math.round(q * def)} ${pkg || 'g'})`; } return `${Math.round(q * 10) / 10} ${u}`; } function _shoppingFamilyInventoryRows(item, smartData, invItems) { const shoppingName = (smartData?.shopping_name || item.name || '').toLowerCase(); const firstTok = (_nameTokens(item.name)[0] || '').toLowerCase(); return invItems.filter(i => { if (parseFloat(i.quantity) <= 0) return false; const iShopping = (i.shopping_name || '').toLowerCase(); if (shoppingName && iShopping === shoppingName) { return _productMatchesShoppingFamily(i.name, shoppingName); } const iFirst = (_nameTokens(i.name || '')[0] || '').toLowerCase(); return firstTok && iFirst === firstTok; }); } /** Exclude mis-tagged products (e.g. passata stored under shopping_name "Pomodori"). */ function _inferShoppingGeneric(name) { const lower = (name || '').toLowerCase(); const phrases = [ ['passata di pomodoro', 'passata'], ['passata pomodoro', 'passata'], ['polpa di pomodoro', 'polpa di pomodoro'], ['polpa pomodoro', 'polpa di pomodoro'], ['sugo al pomodoro', 'sugo'], ['sugo di pomodoro', 'sugo'], ['salsa di pomodoro', 'sugo'], ['datterini pelati', 'pelati'], ['pomodori pelati', 'pelati'], ['pomodoro pelato', 'pelati'], ['pelati', 'pelati'], ['passata', 'passata'], ['tè al limone', 'tè al limone'], ['te al limone', 'tè al limone'], ['panna da cucina', 'panna da cucina'], ['pomodorini', 'pomodorini'], ['pomodoro', 'pomodori'], ['pomodori', 'pomodori'], ]; for (const [phrase, gen] of phrases) { if (lower.includes(phrase)) return gen; } return (_nameTokens(name)[0] || lower).toLowerCase(); } function _productMatchesShoppingFamily(productName, shoppingName) { const sn = (shoppingName || '').toLowerCase().trim(); if (!sn) return false; const productGen = _inferShoppingGeneric(productName); const targetGen = _inferShoppingGeneric(shoppingName); if (productGen === targetGen) return true; const nameLower = (productName || '').toLowerCase().trim(); return nameLower === sn || nameLower.startsWith(sn + ' '); } /** Shopping labels too vague to show alone (use product name / spec instead). */ const _VAGUE_SHOPPING_LABELS = new Set(['misto', 'bevande', 'ver', 'verdure', 'conserva', 'surgelati', 'condimenti']); function _resolveShoppingDisplayName(item, smartData) { const raw = (item.name || '').trim(); const rawLower = raw.toLowerCase(); if (!_VAGUE_SHOPPING_LABELS.has(rawLower)) return raw; const specText = _specDisplayText(item.specification || ''); if (specText) { const head = specText.split(' · ')[0].trim(); if (head && head.toLowerCase() !== rawLower) return head; } if (smartData?.name && smartData.name.toLowerCase() !== rawLower) return smartData.name; return raw; } function _dedupeShoppingByGeneric(enriched) { const seen = new Map(); const out = []; for (const e of enriched) { const key = (e.smartData?.shopping_name || e.item.name).toLowerCase(); if (seen.has(key)) { const prev = seen.get(key); if (!prev.duplicateNames.includes(e.item.name)) prev.duplicateNames.push(e.item.name); continue; } e.duplicateNames = []; seen.set(key, e); out.push(e); } return out; } /** * Build HTML for a price badge column. * @param {Object} entry — API response (price_per_unit, unit_label, estimated_total, source_note) * @param {string} sym — currency symbol like "€" */ function _buildPriceBadgeHTML(entry, sym) { const hasTotal = entry.estimated_total != null; const isApprox = !hasTotal || (entry.source_note || '').startsWith('~'); const mainLabel = (isApprox ? '~' : '') + (hasTotal ? `${sym}${entry.estimated_total.toFixed(2)}` : `${sym}${entry.price_per_unit.toFixed(2)}`); const unitLabel = entry.unit_label || ''; const unitLine = unitLabel && entry.price_per_unit != null ? `${sym}${entry.price_per_unit.toFixed(2)}/${unitLabel}` : ''; const title = entry.source_note || ''; const approxClass = isApprox ? ' price-col-approx' : ''; return `
${mainLabel}
` + (unitLine ? `
${unitLine}
` : ''); } /** * Apply price badges from in-memory cache (_cachedPrices) to the current DOM. * Returns { total, count } of items successfully applied. * Skips entries whose cached qty/unit no longer matches current suggested qty. */ function _applyPriceBadgesFromCache() { const s = getSettings(); const sym = _currencySymbol(s.price_currency || 'EUR'); let total = 0, count = 0; // Build a quick name→{quantity,unit} map from current smart data const qtyMap = {}; for (const p of _buildPricePayload()) qtyMap[p.name] = p; shoppingItems.forEach((item, idx) => { const badge = document.getElementById(`price-badge-${idx}`); if (!badge) return; const entry = _cachedPrices[item.name]; if (!entry) return; // Validate qty/unit — if smart data changed, treat as uncached const current = qtyMap[item.name]; if (current && (entry._qty !== current.quantity || entry._unit !== current.unit)) return; badge.innerHTML = _buildPriceBadgeHTML(entry, sym); if (entry.estimated_total != null) { total += entry.estimated_total; count++; } }); return { total, count }; } /** * Apply price badges to shopping items in the DOM (legacy batch variant). * @param {Object} prices — name → price entry from API * @param {string} currency — currency symbol fallback */ function _applyPriceBadges(prices, currency) { const sym = _currencySymbol(currency); shoppingItems.forEach((item, idx) => { const badge = document.getElementById(`price-badge-${idx}`); if (!badge) return; const entry = prices[item.name]; if (!entry || entry.error) { badge.innerHTML = ``; return; } badge.innerHTML = _buildPriceBadgeHTML(entry, _currencySymbol(entry.currency || currency)); }); } function _currencySymbol(currency) { const map = { EUR: '€', USD: '$', GBP: '£', CHF: 'CHF ', CAD: 'CA$', AUD: 'A$', BRL: 'R$', JPY: '¥', SEK: 'kr', NOK: 'kr', DKK: 'kr', PLN: 'zł', CZK: 'Kč', HUF: 'Ft', RON: 'lei', }; return map[currency?.toUpperCase()] || currency || '€'; } /** * Fetch prices for all shopping list items, one by one (real-time updates). * Uses _cachedPrices for items already fetched this session (no API call needed). * @param {boolean} forceRefresh — bypass all caches, re-fetch everything */ async function fetchAllPrices(forceRefresh = false) { // Disable buttons immediately — even if we bail early, they stay disabled until // the active fetch finishes and re-enables them in its finally block. const fetchBtn = document.getElementById('btn-fetch-prices'); const refreshBtn = document.getElementById('btn-price-refresh'); if (_pricesFetching) { // Already running — don't stack calls, just leave the active fetch to finish return; } if (fetchBtn) fetchBtn.disabled = true; if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = '⏳'; } if (!shoppingItems.length) { if (fetchBtn) fetchBtn.disabled = false; if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; } _clearCanonicalShoppingTotal(); return; } const s = getSettings(); if (!s.price_enabled) { if (fetchBtn) fetchBtn.disabled = false; if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; } return; } if (!_geminiAvailable) { // AI not configured — prices cannot be estimated without Gemini if (fetchBtn) fetchBtn.disabled = false; if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; } return; } _pricesFetching = true; const priceBar = document.getElementById('shopping-price-bar'); const loadingBar = document.getElementById('price-loading-bar'); const loadingInner = loadingBar ? loadingBar.querySelector('.price-loading-inner') : null; const totalEl = document.getElementById('price-total-value'); if (priceBar) priceBar.style.display = 'block'; const sym = _currencySymbol(s.price_currency || 'EUR'); // Show cached badges instantly while the server call is in flight _applyPriceBadgesFromCache(); if (totalEl && forceRefresh) totalEl.textContent = t('shopping.price_loading'); if (loadingBar) loadingBar.style.display = 'block'; if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '5%'; } const lang = s.language || 'it'; const country = s.price_country || 'Italia'; const currency = s.price_currency || 'EUR'; // Send only item names — server resolves qty/unit from smart_shopping_cache const itemsPayload = shoppingItems.map(i => ({ name: i.name })); let serverTotal = null; try { const data = await api('get_all_shopping_prices', {}, 'POST', { items: itemsPayload, country, currency, lang, force_refresh: forceRefresh, }); if (data && data.success) { const prices = data.prices || {}; // Apply each item's result to badges and update in-memory cache shoppingItems.forEach((item, idx) => { const entry = prices[item.name]; const badge = document.getElementById(`price-badge-${idx}`); if (entry && !entry.error && entry.price_per_unit != null) { // Store with server-resolved qty/unit for correct cache validation _cachedPrices[item.name] = { ...entry, _qty: entry._resolved_qty ?? 1, _unit: entry._resolved_unit ?? 'conf', }; if (badge) badge.innerHTML = _buildPriceBadgeHTML(entry, sym); } else { if (badge) badge.innerHTML = ``; } }); // Server is the single source of truth for the total (all UI surfaces) serverTotal = data.total ?? null; const pricedCount = Object.values(prices).filter(e => e && !e.error && e.price_per_unit != null).length; if (serverTotal != null) { _setCanonicalShoppingTotal(serverTotal, currency, { totalLabel: data.total_label, itemCount: shoppingItems.length, pricedCount, }); } } } catch (_err) { if (_canonicalShoppingTotal && totalEl) { totalEl.textContent = _canonicalShoppingTotal.label; } else { const { total: ct, count: cc } = _applyPriceBadgesFromCache(); if (cc > 0 && totalEl) totalEl.textContent = `ca. ${sym}${ct.toFixed(2)}`; } } finally { _pricesFetching = false; try { sessionStorage.setItem('_pricecache', JSON.stringify(_cachedPrices)); sessionStorage.setItem('_pricecachets', String(Date.now())); } catch { /* quota */ } if (loadingBar) { if (loadingInner) loadingInner.style.width = '100%'; setTimeout(() => { loadingBar.style.display = 'none'; }, 300); } if (fetchBtn) fetchBtn.disabled = false; if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; } _applyShoppingTotalDisplay(); } } /** * 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() { // Rate-limit: run at most once every 3 minutes const lastCleanup = parseInt(localStorage.getItem('_bringCleanupTs') || '0'); if (Date.now() - lastCleanup < 3 * 60 * 1000) return; localStorage.setItem('_bringCleanupTs', String(Date.now())); if (!shoppingItems.length || !smartShoppingItems.length) return; // Detect items added automatically by the app (via autoAddCriticalItems). // We rely ONLY on the explicit _autoAddedBring registry (exact name match). // Do NOT use spec markers: autoSyncUrgencySpecs stamps urgency markers (⚡/🟠/🛒) // on ALL matched items regardless of who added them, making marker-based detection // unreliable and causing accidental removal of user-added items. const _autoAdded = _getAutoAddedBring(); const isAppAdded = (item) => !!(_autoAdded[item.name.toLowerCase()]); // Build shopping_name family → total stock from smart_shopping (server already computed this) // If smart says a family is NOT needed, it already excluded them. const smartShoppingNames = new Set( smartShoppingItems.flatMap(si => [ si.name?.toLowerCase(), si.shopping_name?.toLowerCase() ].filter(Boolean)) ); const smartShoppingFirstToks = new Map(); for (const si of smartShoppingItems) { for (const tok of _nameTokens(si.name || '')) { if (!smartShoppingFirstToks.has(tok)) smartShoppingFirstToks.set(tok, si); } if (si.shopping_name) { for (const tok of _nameTokens(si.shopping_name)) { if (!smartShoppingFirstToks.has(tok)) smartShoppingFirstToks.set(tok, si); } } } // Load live inventory from server for stock check let invItems = []; try { const res = await api('inventory_list'); invItems = res.inventory || []; } catch (e) { return; } // stock by any token (name) + by shopping_name const stockByTok = new Map(); const stockBySName = new Map(); for (const inv of invItems) { const qty = parseFloat(inv.quantity || 0); const expiry = inv.expiry_date; const expired = expiry && new Date(expiry) < new Date(); if (qty <= 0 || expired) continue; for (const tok of _nameTokens(inv.name || '')) { stockByTok.set(tok, (stockByTok.get(tok) || 0) + qty); } const sn = (inv.shopping_name || '').toLowerCase().trim(); if (sn) stockBySName.set(sn, (stockBySName.get(sn) || 0) + qty); } const toRemove = []; const _pinned = _pinnedBringCache || {}; for (const item of shoppingItems) { const nameLower = item.name.toLowerCase(); const itemToks = _nameTokens(item.name); const itemFirst = itemToks[0]; // Never remove items explicitly pinned by the user if (_pinned[nameLower]) continue; // Only remove items the app put there if (!isAppAdded(item)) continue; // Find matching smart item const smartSi = itemFirst ? smartShoppingFirstToks.get(itemFirst) : undefined; // Smart still flags this as critical or high → keep it if (smartSi && (smartSi.urgency === 'critical' || smartSi.urgency === 'high')) continue; // Smart says medium AND low stock → keep it if (smartSi && smartSi.urgency === 'medium' && (smartSi.pct_left ?? 100) < 60) continue; // Smart has it with 0 qty → keep it (user genuinely needs it) if (smartSi && (smartSi.current_qty ?? 0) <= 0) continue; // If the item IS still in smart_shopping (but not urgent) AND has no local stock at all, // give benefit of the doubt and keep it. // If the item is NOT in smart_shopping at all → trust the server: it's covered → remove. if (smartSi) { // Still in smart_shopping (low urgency): verify some stock exists before removing const hasStock = itemToks.some(tok => (stockByTok.get(tok) || 0) > 0) || (stockBySName.get(nameLower) || 0) > 0; if (!hasStock) continue; } // else: not in smart_shopping at all → server decided it's covered → safe to remove // All guards passed: app-added and not urgently needed → remove from Bring! toRemove.push(item); } if (toRemove.length === 0) return; let removed = 0; const removedNames = []; for (const item of toRemove) { try { const r = await api('shopping_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) { _unmarkAutoAddedBring(removedNames); 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') || '[]'); const now = Date.now(); log.push({ ts: new Date(now).toISOString(), action, details }); // Prune: keep only last 200 entries AND entries newer than 30 days const cutoff = now - 30 * 24 * 60 * 60 * 1000; const pruned = log.filter(e => new Date(e.ts).getTime() >= cutoff); const final = pruned.length > 200 ? pruned.slice(pruned.length - 200) : pruned; localStorage.setItem('_opLog', JSON.stringify(final)); } 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'; } } function _updateDashboardPriceTotal() { _loadCanonicalTotalFromSession(); _applyShoppingTotalDisplay(); } /** * 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 = t('shopping.smart_last_update').replace('{time}', `${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 = _filterPurchasedSmartItems(data.items); _smartShoppingLastFetch = Date.now(); // NOTE: do NOT clear _cachedPrices here — qty validation (_qty/_unit metadata) // handles stale entries automatically item by item. // 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 = `

${t('shopping.empty_category')}

`; 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 += `
${secDef.icon}${secDef.label}
`; 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 = `
${escapeHtml(shoppingName)}`; if (!isGeneric && item.brand) nameLine += ` ${escapeHtml(item.brand)}`; nameLine += `
`; // 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 = `
${escapeHtml(specifics.join(' · '))}
`; } // 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 — uses per month, not raw transaction count let freqBadge = ''; const usesMonth = Math.round(parseFloat(item.uses_per_month) || 0); if (usesMonth >= 4) freqBadge = `📈 ~${usesMonth}/mese`; else if (usesMonth >= 2) freqBadge = `📊 ~${usesMonth}/mese`; else if (usesMonth >= 1) freqBadge = `📉 ~${usesMonth}/mese`; // Suggested purchase quantity badge let suggestBadge = ''; const sqtyFormatted = _formatSuggestQty(item.suggested_qty, item.suggested_unit || item.unit); if (!item.on_bring && sqtyFormatted) { const approx = !!item.suggested_approx; const tKey = approx ? 'shopping.suggest_buy_approx' : 'shopping.suggest_buy'; const tTip = approx ? 'shopping.suggest_buy_approx_tip' : 'shopping.suggest_buy_tip'; const suggestLabel = t(tKey).replace('{qty} {unit}', sqtyFormatted); const suggestLabelFinal = suggestLabel.includes('{qty}') ? t(tKey).replace('{qty}', item.suggested_qty).replace('{unit}', item.suggested_unit || item.unit) : suggestLabel; const extraClass = approx ? ' freq-suggest-approx' : ''; suggestBadge = `${suggestLabelFinal}`; } // Days left prediction let predBadge = ''; if (item.days_left <= 3 && item.days_left > 0 && item.current_qty > 0) { predBadge = `${t('expiry.badge_days_left').replace('{n}', item.days_left)}`; } else if (item.days_left <= 7 && item.days_left > 0 && item.current_qty > 0) { predBadge = `${t('expiry.badge_days_left').replace('{n}', item.days_left)}`; } // Expiry badge let expiryBadge = ''; if (item.days_to_expiry < 0 && item.current_qty > 0) { expiryBadge = `${t('expiry.badge_expired_bare')}`; } else if (item.days_to_expiry <= 3 && item.days_to_expiry >= 0 && item.current_qty > 0) { expiryBadge = `${t('expiry.badge_expires_warn').replace('{n}', item.days_to_expiry)}`; } return `
${!item.on_bring ? `` : ''} ${catIcon}
${nameLine} ${specificLine}
${item.reasons.map(r => `${escapeHtml(r)}`).join(' · ')}
${u.icon} ${u.label} ${freqBadge}${predBadge}${expiryBadge}${suggestBadge} ${item.is_opened ? `${t('inventory.opened_badge')}` : ''} ${item.on_bring ? `${t('shopping.bring_badge')}` : ''}
${qtyText} ${item.current_qty > 0 ? `
` : ''}
`; } 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(t('shopping.bring_names_migrated', { n: data.migrated }), 'success'); loadShoppingList(); // refresh the shopping list view } else { showToast(t('shopping.names_already_updated'), 'info'); } } else { if (statusEl) statusEl.textContent = '❌ ' + (data.error || t('error.unknown')); } } catch(e) { if (statusEl) statusEl.textContent = '❌ ' + t('scale.error_connect'); } 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; // Specific product/brand prefix (used when item is grouped under a generic name) const productPrefix = isGeneric ? (item.name + (item.brand ? ` · ${item.brand}` : '')) : ''; // Full spec = urgency+qty from _buildSmartSpec, with product prefix prepended if needed const smartSpec = _buildSmartSpec(item); const spec = productPrefix ? (smartSpec ? `${productPrefix} · ${smartSpec}` : productPrefix) : smartSpec; itemsToAdd.push({ name: shoppingName, specification: spec, }); } }); showLoading(true); try { const result = await api('shopping_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 = Object.assign({}, _pinnedBringCache || {}); const now = Date.now(); for (const it of itemsToAdd) pinned[it.name.toLowerCase()] = now; _pinnedBringCache = pinned; _saveToServer('pinned_bring', pinned); } // Reload to refresh badges loadShoppingList(); } else { showToast(result.error || t('error.generic'), 'error'); } } catch (e) { showLoading(false); showToast(t('error.connection'), 'error'); } } // Load just the shopping count for dashboard stat card async function loadShoppingCount() { const el = document.getElementById('stat-spesa'); if (el) el.classList.add('stat-loading'); try { const data = await api('shopping_list'); if (el) { if (data.success && data.purchase) { el.textContent = data.purchase.length; } else { el.textContent = '-'; } el.classList.remove('stat-loading'); } } catch { if (el) { el.textContent = '-'; el.classList.remove('stat-loading'); } } // Smart urgency badge: always fetch fresh data from server (no browser-side gate) try { const smart = await api('smart_shopping'); if (smart.success && smart.items) { smartShoppingItems = smart.items; _smartShoppingLastFetch = Date.now(); _updateSmartUrgencyBadge(); } } catch { /* ignore */ } syncShoppingPriceTotal(false); } /** * 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 = Object.assign({}, _shoppingTagsCache || {}); let changed = false; for (const item of shoppingItems) { const key = item.name.toLowerCase(); const spec = (item.specification || '').toLowerCase(); const existing = (tags[key] || []).slice(); 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) { _shoppingTagsCache = tags; _saveToServer('shopping_tags', 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). */ /** * Format a suggested purchase quantity into a human-readable string. * - conf/pz: returned as-is ("2 conf", "3 pz") * - g ≥ 1000 → kg ("1.5 kg") * - ml ≥ 1000 → l ("2 l") * Returns null if qty is null/zero (badge should be hidden). */ function _formatSuggestQty(qty, unit) { if (!qty || qty <= 0) return null; if (unit === 'conf') return `${qty} conf`; if (unit === 'pz') return `${qty} pz`; if (unit === 'g' && qty >= 1000) { const kg = qty / 1000; return `${Number.isInteger(kg) ? kg : parseFloat(kg.toFixed(1))} kg`; } if (unit === 'ml' && qty >= 1000) { const l = qty / 1000; return `${Number.isInteger(l) ? l : parseFloat(l.toFixed(1))} l`; } return `${qty} ${unit}`; } /** * Build the full Bring! specification string for a matched smart item. * Combines urgency label + suggested quantity so both appear in the Bring app. * Returns empty string for low/medium urgency items with no useful extra info. */ function _buildSmartSpec(smartMatch) { const urgPart = _urgencyToSpec(smartMatch.urgency, ''); let qtyPart = ''; const qtyFormatted = _formatSuggestQty(smartMatch.suggested_qty, smartMatch.suggested_unit || smartMatch.unit); if (qtyFormatted) { const approx = !!smartMatch.suggested_approx; const tKey = approx ? 'shopping.suggest_buy_approx' : 'shopping.suggest_buy'; qtyPart = t(tKey).replace('{qty} {unit}', qtyFormatted); if (qtyPart.includes('{qty}')) { qtyPart = (approx ? '🛒 Almeno: ' : '🛒 Compra: ') + qtyFormatted; } } const parts = [urgPart, qtyPart].filter(Boolean); return parts.join(' · '); } 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 targetSpec = _buildSmartSpec(smartMatch); const currentSpec = (item.specification || '').trim(); if (targetSpec.toLowerCase() === currentSpec.toLowerCase()) continue; // Resolve to generic shopping_name so PHP merges onto the canonical Bring item const apiName = smartMatch.shopping_name || item.name; toUpdate.push({ name: apiName, specification: targetSpec, update_spec: true }); item.specification = targetSpec; } if (toUpdate.length === 0) return; try { await api('shopping_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'); // Track last user interaction timestamp to avoid disrupting active use if (!loadShoppingList._lastUserInteraction) loadShoppingList._lastUserInteraction = 0; // Background refresh: ALWAYS do a silent update — never show spinner or rebuild DOM const isBackgroundCall = loadShoppingList._bgCall === true; loadShoppingList._bgCall = false; if (isBackgroundCall) { try { const data = await api('shopping_list'); if (data.success) { const newItems = data.purchase || []; const newNames = new Set(newItems.map(i => i.name.toLowerCase())); const prevNames = new Set((shoppingItems || []).map(i => i.name.toLowerCase())); const hasChanges = newItems.length !== shoppingItems.length || [...newNames].some(n => !prevNames.has(n)) || [...prevNames].some(n => !newNames.has(n)); if (hasChanges) { shoppingItems = newItems; for (const name of Object.keys(_cachedPrices)) { if (!newNames.has(name.toLowerCase())) delete _cachedPrices[name]; } _syncTagsFromBringSpec(); renderShoppingItems(); syncShoppingPriceTotal(false); } else { loadShoppingCount(); } } } catch(_e) {} return; } statusEl.style.display = 'block'; statusEl.innerHTML = `
${t('shopping.bring_loading')}
`; 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('shopping_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 = `
🔑 ${t('shopping.bring_not_configured') || 'Bring! non è configurato. Aggiungi email e password nelle impostazioni.'}
`; } else { statusEl.innerHTML = `
⚠️ ${escapeHtml(data.error || t('error.bring_connection'))}
`; } 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); } if (data.recently?.length) { _markBringPurchased(data.recently.map(i => i.name).filter(Boolean)); } shoppingItems = newItems; // Evict removed items from price cache so stale prices don't reappear for (const name of Object.keys(_cachedPrices)) { if (!newNames.has(name.toLowerCase())) delete _cachedPrices[name]; } // 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(); // Re-render shopping items ONLY if the user is not currently browsing the suggestions panel. // Avoids interrupting the user mid-selection while background data loads. if (suggestionsEl.style.display === 'none') { renderShoppingItems(); // re-render shopping tab with urgency badges } }); } catch (err) { console.error('Bring! error:', err); statusEl.style.display = 'block'; statusEl.innerHTML = `
${t('error.bring_connection')}
`; } } /** 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', '🟡 a breve', '🔵 previsione']) { 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'); if (shoppingItems.length === 0) { countEl.textContent = 0; const tabCountEmpty = document.getElementById('tab-count-acquisto'); if (tabCountEmpty) tabCountEmpty.textContent = 0; container.innerHTML = `

${t('shopping.empty')}

`; 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; collapse duplicates under the same generic name. const enrichedRaw = shoppingItems.map((item, idx) => { const smartData = _matchBringToSmart(item.name, smartShoppingItems); let urgency = smartData?.urgency || null; 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 }; }); const enriched = _dedupeShoppingByGeneric(enrichedRaw); const pantryRows = enriched; countEl.textContent = enriched.length; const tabCount = document.getElementById('tab-count-acquisto'); if (tabCount) tabCount.textContent = enriched.length; // 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 += `
${secDef.icon}${secDef.label}
`; for (const { item, idx, smartData, urgency, duplicateNames = [] } 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); const shoppingName = smartData?.shopping_name || item.name; const isGenericGroup = smartData && shoppingName.toLowerCase() === item.name.toLowerCase() && (smartData.name !== shoppingName || (smartData.variants || []).length > 0 || duplicateNames.length > 0); const displayName = _resolveShoppingDisplayName(item, smartData); const showAsGeneric = isGenericGroup && displayName.toLowerCase() === shoppingName.toLowerCase(); let specificLineHtml = ''; if (showAsGeneric || duplicateNames.length > 0 || (displayName !== item.name && _specDisplayText(item.specification))) { const specText = _specDisplayText(item.specification); let specifics = []; if (specText) specifics.push(specText); else if (smartData) { specifics.push(smartData.name + (smartData.brand ? ` (${smartData.brand})` : '')); for (const v of (smartData.variants || [])) { specifics.push(v.name + (v.brand ? ` (${v.brand})` : '')); } } for (const dup of duplicateNames) { if (!specifics.some(s => s.toLowerCase().includes(dup.toLowerCase()))) specifics.push(dup); } if (specifics.length) { specificLineHtml = `
${escapeHtml(specifics.join(' · '))}
`; } } // Urgency badge (spec urgency markers are stripped in _specDisplayText) let urgencyBadge = ''; if (urgency && urgencyMap[urgency]) { const u = urgencyMap[urgency]; urgencyBadge = `${u.icon} ${u.label}`; } // Frequency: uses per month (not raw transaction count) let freqBadge = ''; const usesMonth = smartData ? Math.round(parseFloat(smartData.uses_per_month) || 0) : 0; if (usesMonth >= 4) freqBadge = `📈 ~${usesMonth}/mese`; else if (usesMonth >= 2) freqBadge = `📊 ~${usesMonth}/mese`; const localTagHtml = localTags.map(t => `${TAG_LABELS[t] || t} ✕` ).join(''); const tagMenu = `
${Object.entries(TAG_LABELS).map(([k, v]) => `` ).join('')}
`; const priceEnabled = getSettings().price_enabled; html += `
${catIcon}
${escapeHtml(displayName)} 📷
${specificLineHtml} ${(!isGenericGroup && _specDisplayText(item.specification)) ? `
${escapeHtml(_specDisplayText(item.specification))}
` : ''} ${(urgencyBadge || freqBadge || localTagHtml) ? `
${urgencyBadge}${freqBadge}${localTagHtml}
` : ''}
${priceEnabled ? `
` : ''}
`; } } container.innerHTML = html; // ── PANTRY HINTS: show "already at home: X" for each shopping item ────── // Load inventory once, then decorate all items asynchronously. _getShoppingInventoryCache().then(invItems => { for (const { item, idx, smartData, urgency } of pantryRows) { const matches = _shoppingFamilyInventoryRows(item, smartData, invItems); if (matches.length === 0) continue; // Don't show "already at home" when the item is flagged urgent — stock is clearly insufficient. if (urgency === 'critical' || urgency === 'high') continue; const parts = []; const byKey = {}; for (const m of matches) { const label = _formatInvQtyDisplay(m.quantity, m.unit, m.default_quantity, m.package_unit); if (!label) continue; byKey[label] = (byKey[label] || 0) + 1; } for (const [label, n] of Object.entries(byKey)) { parts.push(n > 1 ? `${label} (${n} prodotti)` : label); } if (!parts.length) continue; const hintText = parts.join(', '); const itemEl = document.getElementById(`shop-item-${idx}`); if (!itemEl) continue; const infoEl = itemEl.querySelector('.shopping-item-info'); if (!infoEl) continue; // Don't duplicate if (infoEl.querySelector('.shopping-pantry-hint')) continue; const hintEl = document.createElement('div'); hintEl.className = 'shopping-pantry-hint'; hintEl.textContent = t('shopping.pantry_hint').replace('{qty}', hintText); infoEl.appendChild(hintEl); } }); // Trigger async price loading if enabled const s2 = getSettings(); if (s2.price_enabled && shoppingItems.length > 0) { document.getElementById('shopping-price-bar').style.display = 'block'; document.getElementById('btn-fetch-prices').style.display = 'inline-flex'; // Allow a new fetch (re-render may have happened while old fetch was running) _pricesFetching = false; if (smartShoppingItems.length === 0 && _smartShoppingLastFetch === 0) { // Smart data hasn't loaded yet — show cached badges silently. // loadSmartShopping().then() will call renderShoppingItems() again with real data. _applyPriceBadgesFromCache(); } else { // Always ask the server — it has a 5-min total cache and responds instantly // if data is fresh. This guarantees every client sees the same prices. // Show cached badges instantly while the server call is in flight. _applyPriceBadgesFromCache(); fetchAllPrices(false); } } else { document.getElementById('shopping-price-bar').style.display = 'none'; document.getElementById('btn-fetch-prices').style.display = 'none'; } } function toggleShoppingTagMenu(btn) { loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list 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) { loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list const item = shoppingItems[idx]; if (!item) return; try { const data = await api('shopping_remove', {}, 'POST', { name: item.name, rawName: item.rawName || '', listUUID: shoppingListUUID }); if (data.success) { _markBringPurchased([item.name]); // prevent background sync from re-adding before barcode scan 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 = `
${t('shopping.suggest_loading')}`; suggestionsEl.style.display = 'none'; try { const data = await api('shopping_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 })) // Exclude items already present in the current Bring shopping list .filter(s => { const sFirst = _nameTokens(s.name)[0]; const sLower = s.name.toLowerCase(); return !shoppingItems.some(bi => { const bLower = bi.name.toLowerCase(); const bFirst = _nameTokens(bi.name)[0]; return bLower === sLower || (sFirst && bFirst && bFirst === sFirst); }); }); // Show seasonal tip const tipEl = document.getElementById('seasonal-tip'); if (data.seasonal_tip) { tipEl.style.display = 'block'; tipEl.innerHTML = `🌿 ${escapeHtml(data.seasonal_tip)}`; } 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 isAi = item.source === 'ai'; const priorityBadge = { 'alta': `${t('shopping.priority_high')}`, 'media': `${t('shopping.priority_medium')}`, 'bassa': `${t('shopping.priority_low')}`, }[item.priority] || ''; const aiBadge = isAi ? `🤖 AI` : ''; return `
${item.selected ? '☑️' : '⬜'}
${catIcon}
${escapeHtml(item.name)}${item.specification ? ` (${escapeHtml(item.specification)})` : ''} ${priorityBadge}${aiBadge}
${escapeHtml(item.reason)}
`; }).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, '"'); 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 = `💡 ${escapeHtml(enriched.tip)}`; 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 = `
${t('shopping.bring_adding')}`; try { const items = selected.map(s => { return { name: s.name }; }); const data = await api('shopping_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 = `

${t('scanner.expiry_label_hint')}

`; 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 = `

⚠️ Impossibile accedere alla fotocamera

`; } } 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 = `

${t('scanner.ai_analyzing')}

`; 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 (treat as user-provided) const expiryInput = document.getElementById('add-expiry'); if (expiryInput) { expiryInput.value = result.expiry_date; expiryInput.dataset.manuallySet = 'true'; } statusDiv.innerHTML = `

✅ ${t('scanner.expiry_found')}: ${formatDate(result.expiry_date)}

`; // Close modal after delay setTimeout(() => closeExpiryScanner(), 1500); } else if (result.error === 'no_api_key') { statusDiv.innerHTML = `

${t('ai.no_api_key').replace(/\n/g, '
')}

`; } else { statusDiv.innerHTML = `

❌ ${t('scanner.expiry_read_fail')} ${result.raw_text ? '
' + t('scanner.expiry_raw_label') + ': ' + escapeHtml(result.raw_text) + '' : ''}

`; } } catch (err) { console.error('Expiry AI error:', err); statusDiv.innerHTML = `

❌ ${t('error.network_retry')}

`; } } function stripHtml(str) { if (!str) return ''; return str.replace(/<[^>]*>/g, ''); } 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 = '

' + t('app.loading') + '

'; } 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 = `

${t('log.empty')}

`; } 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 += `
${dateStr}
`; 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 ? ` (${tx.brand})` : ''; 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 ? `
🍳 ${escapeHtml(tx.notes)}
` : ''; 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 += `
`; html += `${icon}`; html += `
`; html += `
${escapeHtml(tx.name)}${brand}${undone ? ` ${t('log.undone_badge')}` : ''}
`; const txQtyStr = tx.type !== 'bring' ? formatQuantity(parseFloat(tx.quantity), tx.unit, tx.default_quantity, tx.package_unit) + ' · ' : ''; html += `
${typeLabel} ${txQtyStr}${locStr}${notes} · ${timeStr}
`; html += recipeNote; html += `
`; if (canUndo) { html += ``; } html += `
`; }); } 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 = `

${t('log.load_error')}

`; } } 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', ` ${t('log.undone_badge')}`); } } } 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 ===== /** /** * Keywords to check in inventory names for each meal plan type. * Mirror of PHP $typeKeywords in api/index.php. */ const MEAL_PLAN_TYPE_KEYWORDS = { pesce: ['tonno','salmone','merluzzo','branzino','orata','sardine','acciughe','alici','gamberi','cozze','vongole','polpo','calamari','seppia','sgombro','trota','baccalà','dentice','spigola','pesce'], carne: ['pollo','manzo','maiale','vitello','agnello','tacchino','salsiccia','hamburger','bistecca','cotoletta','pancetta','speck','carne','arrosto','filetto','lonza','braciola'], pasta: ['pasta','spaghetti','penne','rigatoni','fusilli','tagliatelle','lasagne','farfalle','orecchiette','bucatini','linguine','maccheroni','gnocchi','pennette','bavette'], riso: ['riso','basmati','arborio','carnaroli','parboiled'], legumi: ['fagioli','ceci','lenticchie','piselli','fave','lupini','soia','legumi','borlotti','cannellini','azuki'], uova: ['uova','uovo'], formaggio: ['formaggio','parmigiano','mozzarella','ricotta','pecorino','grana','gorgonzola','scamorza','fontina','emmental','asiago','provola','provolone','taleggio','stracchino'], pizza: ['farina','lievito','pizza','focaccia'], affettati: ['prosciutto','salame','bresaola','mortadella','speck','coppa','affettati','wurstel','piadina'], verdure: ['zucchine','zucchina','melanzane','peperoni','spinaci','cavolfiore','broccoli','carote','zucca','bietole','cavolo','carciofi','asparagi','lattuga','rucola','radicchio','finocchio','cipolla','porri','verdure'], zuppa: ['brodo','zuppa','minestra','minestrone','orzo','farro','fagioli','ceci','lenticchie'], insalata: ['insalata','lattuga','rucola','spinaci','radicchio','misticanza','valeriana','songino'], pane: ['pane','pancarrè','baguette','toast','tramezzino','crackers','grissini','ciabatta'], dolce: ['cioccolato','cacao','zucchero','miele','marmellata','nutella','savoiardi','biscotti','panna'], }; /** * Check if today's meal plan type has at least one ingredient in the inventory. * Returns true if available (or type is unknown/libero), false if definitely missing. */ async function _checkMealPlanIngredientAvailable(typeId) { if (!typeId || typeId === 'libero') return true; const keywords = MEAL_PLAN_TYPE_KEYWORDS[typeId]; if (!keywords || keywords.length === 0) return true; try { const data = await api('inventory_list'); const items = (data.inventory || []).filter(i => parseFloat(i.quantity) > 0); for (const item of items) { const nameLower = (item.name + ' ' + (item.brand || '')).toLowerCase(); for (const kw of keywords) { if (nameLower.includes(kw)) return true; } } return false; } catch { return true; // on error, assume available to avoid blocking UI } } /** * 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'; } function onPriceCountryChange() { // Auto-suggest currency based on country const countryEl = document.getElementById('setting-price-country'); const currencyEl = document.getElementById('setting-price-currency'); if (!countryEl || !currencyEl) return; const map = { 'USA': 'USD', 'UK': 'GBP', 'Switzerland': 'CHF', 'Canada': 'CAD', 'Australia': 'AUD', 'Brazil': 'BRL', 'Japan': 'JPY', 'Sweden': 'SEK', 'Norway': 'NOK', 'Denmark': 'DKK', 'Poland': 'PLN', }; const suggested = map[countryEl.value]; if (suggested) currencyEl.value = suggested; // Default to EUR for EU countries } /** * 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 = `
🌤️ ${t('meal_types.pranzo')} 🌙 ${t('meal_types.cena')}
`; 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 `
${weekDaysShort[i]}
${pt.icon} ${pt.label} ${ct.icon} ${ct.label}
`; }).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 => `` ).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(); // Persist to server for cross-device sync api('app_settings_save', {}, 'POST', { settings: { meal_plan: s.meal_plan } }).catch(() => {}); } 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'); api('app_settings_save', {}, 'POST', { settings: { meal_plan: s.meal_plan } }).catch(() => {}); } // ===== 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 = `
🍳

${t('recipes.archive_empty')}

`; 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 += `
`; html += `
${escapeHtml(dateLabel)}
`; 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); const favBadge = entry.is_favorite ? `` : ''; html += `
`; html += `
`; html += `${mealIcon}`; html += `${escapeHtml(r.title)}`; html += favBadge; html += `
`; html += `
`; if (r.prep_time) html += `🔪 ${r.prep_time}`; if (r.cook_time) html += `🔥 ${r.cook_time}`; html += `👥 ${r.persons}`; if (tags) html += `${tags}`; html += `
`; flatIdx++; } html += `
`; } container.innerHTML = html; } // ===== RECIPE LIBRARY (recettes manuelles, ex: cocktails) ===== let RECIPE_TAGS = []; let _recipeLibraryActiveTagFilter = null; async function loadRecipeTags() { try { const result = await api('recipe_tags_list', {}, 'GET'); if (result.success && Array.isArray(result.tags)) { RECIPE_TAGS = result.tags; } } catch (e) { console.warn('[EverShelf] Could not load recipe tags:', e); } } let CUSTOM_UNITS = []; async function loadCustomUnits() { try { const result = await api('custom_units_list', {}, 'GET'); if (result.success && Array.isArray(result.units)) { CUSTOM_UNITS = result.units; } } catch (e) { console.warn('[EverShelf] Could not load custom units:', e); } } let _recipeLibraryCache = []; async function loadRecipeLibrary() { const container = document.getElementById('recipe-library-list'); if (!container) return; container.innerHTML = `

Chargement…

`; try { const result = await api('recipe_library_list', {}, 'GET'); if (!result.success) { container.innerHTML = `

Erreur de chargement.

`; return; } _recipeLibraryCache = result.recipes; renderRecipeLibraryTagFilterBar(); renderRecipeLibraryList(_recipeLibraryCache); } catch (e) { container.innerHTML = `

Erreur de chargement.

`; } } function renderRecipeLibraryTagFilterBar() { const container = document.getElementById('recipe-library-tag-filter'); if (!container) return; const usedKeys = new Set(); _recipeLibraryCache.forEach(entry => (entry.recipe.tags || []).forEach(k => usedKeys.add(k))); const usedTags = RECIPE_TAGS.filter(t => usedKeys.has(t.key)); if (usedTags.length === 0) { container.innerHTML = ''; return; } container.innerHTML = usedTags.map(tag => { const active = _recipeLibraryActiveTagFilter === tag.key; return ``; }).join(''); } function setRecipeLibraryTagFilter(key) { _recipeLibraryActiveTagFilter = (_recipeLibraryActiveTagFilter === key) ? null : key; renderRecipeLibraryTagFilterBar(); renderRecipeLibraryList(_recipeLibraryCache); } function renderRecipeLibraryList(recipes) { const container = document.getElementById('recipe-library-list'); if (!container) return; const filtered = _recipeLibraryActiveTagFilter ? recipes.filter(entry => (entry.recipe.tags || []).includes(_recipeLibraryActiveTagFilter)) : recipes; if (!filtered || filtered.length === 0) { container.innerHTML = `

Aucune recette${_recipeLibraryActiveTagFilter ? ' avec ce tag' : ' pour l\'instant'}.

`; return; } container.innerHTML = filtered.map(entry => { const r = entry.recipe; const favBadge = entry.is_favorite ? `` : ''; const ingCount = (r.ingredients || []).length; const tagBadges = (r.tags || []).map(k => { const tag = RECIPE_TAGS.find(t => t.key === k); return tag ? `${tag.icon} ${escapeHtml(tag.label)}` : ''; }).join(''); return `
🍹 ${escapeHtml(r.title)} ${favBadge}
${ingCount} ingrédient${ingCount > 1 ? 's' : ''}
${tagBadges ? `
${tagBadges}
` : ''}
`; }).join(''); } function openRecipeLibraryImportForm() { document.getElementById('modal-content').innerHTML = `
`; document.getElementById('modal-overlay').style.display = 'flex'; } function _normalizeForMatch(s) { return (s || '').toString().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } function matchRecipeTagsFromIngredients(ingredients) { const norm = _normalizeForMatch(ingredients.map(i => i.name).join(' ')); const matched = []; (RECIPE_TAGS || []).forEach(tag => { const raw = (tag.keywords && tag.keywords.trim()) ? tag.keywords : tag.key; const kws = raw.split(',').map(k => _normalizeForMatch(k.trim())).filter(Boolean); if (kws.some(kw => kw.length > 1 && norm.includes(kw))) { matched.push(tag.key); } }); return matched; } function analyzeRawRecipeText() { const raw = document.getElementById('rl-import-raw').value; if (!raw.trim()) { showToast('Colle un texte de recette', 'warning'); return; } const parsed = parseRawRecipeText(raw); parsed.tags = matchRecipeTagsFromIngredients(parsed.ingredients); openRecipeLibraryForm(null, parsed); } function parseRawRecipeText(raw) { const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0); const ingredientHeaderRe = /^(ingr[ée]dients?)\s*:?$/i; const equipmentHeaderRe = /^(mat[ée]riel|[ée]quipements?|ustensiles?)\s*:?$/i; const stepsHeaderRe = /^([ée]tapes?|pr[ée]paration|instructions?)\s*:?$/i; let title = ''; let section = null; const ingredients = []; const equipment = []; const steps = []; for (const line of lines) { if (ingredientHeaderRe.test(line)) { section = 'ingredients'; continue; } if (equipmentHeaderRe.test(line)) { section = 'equipment'; continue; } if (stepsHeaderRe.test(line)) { section = 'steps'; continue; } if (!title && !section) { title = line.replace(/^[-•*]\s*/, ''); continue; } if (!section) continue; if (section === 'ingredients') { const cleaned = line.replace(/^[-•*]\s*/, ''); const m = cleaned.match(/^([\d,.\/]+\s*(?:g|kg|ml|cl|l|tsp|tbsp|cup|c\.à\.s|c\.à\.c|pincée|pinch)?)\s*(?:de\s+|d')?(.+)$/i); if (m && m[1] && m[2]) { ingredients.push({ name: m[2].trim(), qty: m[1].trim() }); } else { ingredients.push({ name: cleaned, qty: '' }); } } else if (section === 'equipment') { equipment.push(line.replace(/^[-•*]\s*/, '')); } else if (section === 'steps') { steps.push(line.replace(/^(\d+[.)]\s*|[-•*]\s*)/, '')); } } return { title: title || 'Recette importée', ingredients: ingredients.length ? ingredients : [{ name: '', qty: '' }], equipment, steps: steps.length ? steps : [''], tags: [], persons: 1, }; } function openRecipeLibraryForm(id = null, prefill = null) { const existing = id ? _recipeLibraryCache.find(e => e.id === id) : null; const r = existing ? existing.recipe : (prefill || { title: '', ingredients: [{ name: '', qty: '' }], equipment: [], steps: [''] }); document.getElementById('modal-content').innerHTML = `
${r.ingredients.map(ing => `
`).join('')}
${(r.equipment || []).map(eq => `
`).join('')}
${r.steps.map(step => `
`).join('')}
${RECIPE_TAGS.map(tag => { const selected = (r.tags || []).includes(tag.key); return ``; }).join('')}
`; document.getElementById('modal-overlay').style.display = 'flex'; } function addRecipeLibraryRow(containerId, type) { const container = document.getElementById(containerId); if (!container) return; const row = document.createElement('div'); row.className = 'barcode-input-row'; row.style.marginBottom = '6px'; if (type === 'ingredient') { row.innerHTML = ` `; } else if (type === 'equipment') { row.innerHTML = ` `; } else { row.innerHTML = ` `; } container.appendChild(row); } async function submitRecipeLibraryForm(e, id) { e.preventDefault(); const title = document.getElementById('rl-title').value.trim(); if (!title) { showToast('Le titre est obligatoire', 'warning'); return; } const ingredients = Array.from(document.querySelectorAll('#rl-ingredients-list .barcode-input-row')).map(row => ({ name: row.querySelector('.rl-ing-name')?.value.trim() || '', qty: row.querySelector('.rl-ing-qty')?.value.trim() || '', })).filter(ing => ing.name); const equipment = Array.from(document.querySelectorAll('#rl-equipment-list .rl-equipment-text')).map(input => input.value.trim()).filter(Boolean); const steps = Array.from(document.querySelectorAll('#rl-steps-list .rl-step-text')).map(input => input.value.trim()).filter(Boolean); const tags = Array.from(document.querySelectorAll('#rl-tags-picker .rl-tag-chip.btn-primary')).map(b => b.dataset.tag); const recipe = { title, ingredients, equipment, steps, tags, persons: 1 }; showLoading(true); try { const result = await api('recipe_library_save', {}, 'POST', { id, recipe }); showLoading(false); if (result.success) { showToast('Recette enregistrée', 'success'); closeModal(); loadRecipeLibrary(); } else { showToast(result.error || 'Erreur', 'error'); } } catch (e) { showLoading(false); showToast('Erreur', 'error'); } } function viewRecipeLibraryItem(id) { const entry = _recipeLibraryCache.find(e => e.id === id); if (!entry) return; const r = entry.recipe; document.getElementById('modal-content').innerHTML = `
${(r.equipment && r.equipment.length) ? `
` : ''}
    ${(r.steps || []).map(s => `
  1. ${escapeHtml(s)}
  2. `).join('')}
`; document.getElementById('modal-overlay').style.display = 'flex'; } async function toggleRecipeLibraryFavorite(id) { await api('recipe_library_toggle_favorite', {}, 'POST', { id }); closeModal(); loadRecipeLibrary(); } async function deleteRecipeLibraryItem(id) { if (!confirm('Supprimer cette recette ?')) return; await api('recipe_library_delete', {}, 'POST', { id }); closeModal(); showToast('Recette supprimée', 'success'); loadRecipeLibrary(); } function viewArchivedRecipe(idx) { const pick = _recipeArchiveEntries[idx]; if (!pick) return; _recipeArchiveCache = null; getRecipeArchive().then(archive => { const entry = archive.find(e => e.id === pick.id) || pick; _cachedRecipe = { meal: _normalizeMealId(entry.meal), recipe: entry.recipe, id: entry.id, is_favorite: !!entry.is_favorite }; return renderRecipe(entry.recipe); }).then(() => { 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 _recipeShoppingSuggestions = []; 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 ``; }).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, items } let _recipeUseConfMode = null; let _recipeUseNormalUnit = 'pz'; function _parseRecipeQtyString(qtyStr) { const m = (qtyStr || '').match(/(\d+(?:[.,]\d+)?)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i); if (!m) return { val: 0, unit: '' }; let val = parseFloat(m[1].replace(',', '.')); const ru = m[2].toLowerCase(); let unit = ''; if (ru.startsWith('g')) unit = 'g'; else if (ru === 'kg') { unit = 'g'; val *= 1000; } else if (ru === 'ml') unit = 'ml'; else if (ru === 'cl') { unit = 'ml'; val *= 10; } else if (ru === 'l' || ru.startsWith('litr')) { unit = 'ml'; val *= 1000; } else if (ru.startsWith('pz') || ru.startsWith('pezz')) unit = 'pz'; else if (ru.startsWith('conf')) unit = 'conf'; return { val, unit }; } function _normalizeRecipeIngQtyNumber(ing) { const parsed = _parseRecipeQtyString(ing.qty || ''); const recipeVal = parsed.val; const recipeUnit = parsed.unit; const unit = ing.inventory_unit || 'pz'; const pkgSize = parseFloat(ing.default_quantity) || 0; const pkgUnit = (ing.package_unit || '').toLowerCase(); const isConfSub = unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml'); let useQty = parseFloat(ing.qty_number) || 0; const stockPieces = parseFloat(ing.inventory_qty_total ?? ing.inventory_qty) || 0; if (unit === 'pz') { useQty = _recipeResolvePieceQty(useQty, recipeVal, recipeUnit, stockPieces); ing.qty_number = Math.round(useQty * 1000) / 1000; ing.qty = _recipeFormatPieceQtyLabel(useQty); return useQty; } if (isConfSub && recipeVal > 0 && recipeUnit === pkgUnit) { useQty = recipeVal; ing.qty_number = Math.round(useQty * 1000) / 1000; ing.qty = Math.round(useQty) + ' ' + pkgUnit; } else if (isConfSub && useQty > 0 && useQty <= (parseFloat(ing.inventory_qty_total ?? ing.inventory_qty) || 0)) { // Legacy: qty_number stored as conf count — convert to sub-units only if not matching recipe string if (!(recipeVal > 0 && recipeUnit === pkgUnit)) { useQty = Math.round(useQty * pkgSize); ing.qty_number = useQty; ing.qty = useQty + ' ' + pkgUnit; } } return useQty; } function _recipeRoundPieceQty(n) { return Math.max(0.25, Math.round(n * 4) / 4); } function _recipeFormatPieceQtyLabel(n) { const whole = Math.floor(n); const frac = Math.round((n - whole) * 4) / 4; const fracMap = { 0.25: '¼', 0.5: '½', 0.75: '¾' }; const fracStr = fracMap[frac] || ''; if (whole === 0) return (fracStr || '0') + ' pz'; return whole + fracStr + ' pz'; } /** Piece inventory only — never derive count from default_quantity / grams. */ function _recipeResolvePieceQty(rawQty, recipeVal, recipeUnit, stockPieces) { stockPieces = Math.max(0, stockPieces); if (recipeUnit === 'pz' && recipeVal > 0) { return _recipeRoundPieceQty(Math.min(recipeVal, stockPieces > 0 ? stockPieces : recipeVal)); } if (rawQty >= 0.25 && rawQty <= Math.min(stockPieces > 0 ? stockPieces : 50, 50)) { return _recipeRoundPieceQty(rawQty); } if (rawQty >= 20 && (stockPieces <= 0 || rawQty > stockPieces)) { return _recipeRoundPieceQty(Math.min(1, stockPieces > 0 ? stockPieces : 1)); } if (recipeVal >= 0.25 && recipeVal <= 50 && !['g', 'ml', 'kg', 'l'].includes(recipeUnit)) { return _recipeRoundPieceQty(Math.min(recipeVal, stockPieces > 0 ? stockPieces : recipeVal)); } return _recipeRoundPieceQty(Math.min(1, stockPieces > 0 ? stockPieces : 1)); } function _recipeGetServingCapForIngredient(name, unit, persons) { if (!persons || persons <= 0) return null; const n = (name || '').toLowerCase().replace(/\s+/g, ' '); if (unit === 'pz') { if (/\b(cipoll\w*|porr\w*|scalog\w*)\b/.test(n)) return persons; if (/\b(peperon\w*|melanzan\w*|zucchin\w*|finocchi\w*|melone)\b/.test(n)) return persons; if (/\b(limon\w*|aranc\w*|limett\w*)\b/.test(n)) return Math.max(1, Math.ceil(0.5 * persons)); if (/\b(dado|brodo)\b/.test(n)) return Math.min(persons, 1); if (/\b(baulett\w*|panin\w*|toast|piadin\w*|grissin\w*)\b/.test(n)) return Math.min(2, persons); return null; } if (unit === 'g' || unit === 'ml') { if (/\b(spinac\w*|bietol\w*|rucol\w*|lattug\w*|valerian\w*|songin\w*|misticanz\w*|indivi\w*|radicchi\w*|cicori\w*)\b/.test(n)) return 150 * persons; if (/\b(minestr\w*|verdure)\b/.test(n)) return 200 * persons; if (/\b(pane\s*gratt|grattugi\w*|pangratt)\b/.test(n)) return 30 * persons; if (/\b(zucchin\w*|melanzan\w*|peperon\w*|carot\w*|sedan\w*|finocchi\w*|cavolf\w*|broccol\w*|zucc\w*|pomodor\w*|verdur\w*)\b/.test(n)) return 150 * persons; } return null; } function _recipeClampQtyForServings(ing, persons) { if (!persons || persons <= 0) return; const unit = ing.inventory_unit || 'pz'; let qty = parseFloat(ing.qty_number) || 0; if (qty <= 0) return; const cap = _recipeGetServingCapForIngredient(ing.name, unit, persons); if (cap === null || qty <= cap) return; ing.qty_number = Math.round(cap * 100) / 100; if (unit === 'pz') ing.qty = _recipeFormatPieceQtyLabel(cap); else if (unit === 'g' || unit === 'ml') ing.qty = Math.round(cap) + ' ' + unit; delete ing.use_all_suggested; if (ing.stock_have != null) ing.stock_remain = Math.max(0, Math.round((ing.stock_have - cap) * 100) / 100); } function _recipeGetClosedProductBaseQty(ing) { const unit = ing.inventory_unit || 'pz'; const pkgSize = parseFloat(ing.default_quantity) || 0; const pkgUnit = (ing.package_unit || '').toLowerCase(); // Countable items: one piece = one unit — ignore default_quantity weight in grams if (unit === 'pz') return 1; if (unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml')) { return pkgSize; } if (unit === 'conf' && pkgSize > 0) { return pkgSize; } if (pkgSize > 0 && (unit === 'g' || unit === 'ml')) { return pkgSize; } if (unit === 'conf') { return 1; } return 0; } function _recipeShouldUseAllRemainder(remainDisp, ing, stockDisp = 0) { if (remainDisp <= 0) return false; const packageBase = _recipeGetClosedProductBaseQty(ing); if (packageBase <= 0) return false; const pct = remainDisp / packageBase; if (pct < 0.05) return true; if (stockDisp > 0 && stockDisp < packageBase && pct < 0.10) return true; return false; } function _computeRecipeIngStockHint(ing, totalStockQty) { _normalizeRecipeIngQtyNumber(ing); const unit = ing.inventory_unit || 'pz'; const pkgSize = parseFloat(ing.default_quantity) || 0; const pkgUnit = (ing.package_unit || '').toLowerCase(); const isConfSub = unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml'); let useQty = parseFloat(ing.qty_number) || 0; let stockDisp, useDisp, dispUnit; if (isConfSub) { stockDisp = totalStockQty * pkgSize; useDisp = useQty; dispUnit = pkgUnit; } else { stockDisp = totalStockQty; useDisp = useQty; dispUnit = unit; } let remainDisp = Math.max(0, stockDisp - useDisp); let useAll = false; if (stockDisp > 0 && useDisp > 0 && _recipeShouldUseAllRemainder(remainDisp, ing, stockDisp)) { useAll = true; useDisp = stockDisp; remainDisp = 0; ing.use_all_suggested = true; if (isConfSub) { ing.qty_number = Math.round(useDisp * 10) / 10; ing.qty = Math.round(useDisp) + ' ' + pkgUnit; } else { ing.qty_number = Math.round(totalStockQty * 1000) / 1000; ing.qty = unit === 'pz' ? _recipeFormatPieceQtyLabel(totalStockQty) : Math.round(totalStockQty) + ' ' + unit; } } else { delete ing.use_all_suggested; } ing.stock_have = Math.round(stockDisp * 100) / 100; ing.stock_remain = Math.round(remainDisp * 100) / 100; ing.stock_unit = dispUnit; ing.package_base = _recipeGetClosedProductBaseQty(ing); return { stockDisp, remainDisp, dispUnit, useAll }; } function _formatRecipeStockQty(n, dispUnit, ing) { if (dispUnit === 'g' || dispUnit === 'ml') { const rounded = n < 10 ? Math.round(n * 10) / 10 : Math.round(n); return rounded + ' ' + dispUnit; } return stripHtml(formatQuantity(n, ing.inventory_unit || 'pz', ing.default_quantity, ing.package_unit)); } function _recipeIngStockHintHtml(ing) { if (!ing.from_pantry || ing.stock_have == null) return ''; const have = _formatRecipeStockQty(ing.stock_have, ing.stock_unit || ing.inventory_unit, ing); const remain = _formatRecipeStockQty(ing.stock_remain, ing.stock_unit || ing.inventory_unit, ing); let line = t('recipes.ing_stock_line').replace('{have}', have).replace('{remain}', remain); if (ing.use_all_suggested) line += ' · ' + t('recipes.ing_use_all_note'); return line; } async function enrichRecipeIngredientsStock(recipe) { if (!recipe || !recipe.ingredients) return recipe; try { const data = await api('inventory_list'); const inv = data.inventory || []; for (const ing of recipe.ingredients) { if (!ing.from_pantry || !ing.product_id) continue; const rows = inv.filter(i => i.product_id == ing.product_id); const activeRows = rows.filter(i => parseFloat(i.quantity) > 0); if (!activeRows.length) { ing.from_pantry = false; delete ing.product_id; delete ing.stock_have; delete ing.stock_remain; continue; } const totalStock = activeRows.reduce((s, i) => s + parseFloat(i.quantity), 0); ing.inventory_qty_total = totalStock; const opened = activeRows.find(_isOpenedInventoryItem); const pick = opened || activeRows.find(r => r.location === ing.location) || activeRows[0]; if (pick) { ing.inventory_unit = pick.unit; ing.default_quantity = pick.default_quantity; ing.package_unit = pick.package_unit; } _computeRecipeIngStockHint(ing, totalStock); _recipeClampQtyForServings(ing, Math.max(1, parseInt(recipe.persons, 10) || 1)); } } catch (e) { console.warn('enrichRecipeIngredientsStock:', e); } return recipe; } function _updateRecipeStockHintsAfterScale(ratio) { const ings = _cachedRecipe?.recipe?.ingredients || []; document.querySelectorAll('#recipe-content .recipe-ingredient[data-ing-idx]').forEach(li => { const idx = parseInt(li.dataset.ingIdx, 10); const ing = ings[idx]; if (!ing || ing.stock_have == null) return; const baseUse = parseFloat(li.dataset.baseQty || '0'); if (baseUse <= 0) return; const unit = ing.inventory_unit || 'pz'; const pkgSize = parseFloat(ing.default_quantity) || 0; const pkgUnit = (ing.package_unit || '').toLowerCase(); const isConfSub = unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml'); const stockDisp = ing.stock_have; let useDisp = baseUse * ratio; let remainDisp = Math.max(0, stockDisp - useDisp); let useAll = false; if (stockDisp > 0 && useDisp > 0 && _recipeShouldUseAllRemainder(remainDisp, ing, stockDisp)) { useAll = true; useDisp = stockDisp; remainDisp = 0; } const stockEl = li.querySelector('.recipe-ing-stock'); if (!stockEl) return; const have = _formatRecipeStockQty(stockDisp, ing.stock_unit || unit, ing); const remain = _formatRecipeStockQty(remainDisp, ing.stock_unit || unit, ing); let line = t('recipes.ing_stock_line').replace('{have}', have).replace('{remain}', remain); if (useAll) line += ' · ' + t('recipes.ing_use_all_note'); stockEl.textContent = line; }); } 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; // Reset scale state: set the current weight as baseline so only a *change* // of ≥5g after the modal opens triggers auto-fill (allows time to tare). _cancelScaleAutoConfirm(false); _scaleRecipeAutoFillPaused = false; if (_scaleLatestWeight) { const _baseline = _scaleToGrams(parseFloat(_scaleLatestWeight.value), _scaleLatestWeight.unit); if (_baseline !== null && _baseline >= 5) _scaleLastConfirmedGrams = _baseline; } // Fetch inventory to build the modal try { const data = await api('inventory_list'); const items = (data.inventory || []).filter(i => i.product_id == productId); _recipeUseContext.items = items; // cache for "use all" quantity lookup 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) ? ` 🔓 ${t('use.opened_badge')}` : ''; return ``; }).join(''); // Build quantity controls let qtySection = ''; let defaultQtyValue = Math.round(qtyNumber * 10) / 10; 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 = (pkgUnit === 'g' || pkgUnit === 'ml') ? Math.round(qtyNumber) : Math.round(qtyNumber * 10) / 10; qtySection = `

${t('recipes.quantity_in_total').replace('{unit}', subLabel).replace('{total}', Math.round(totalSub) + subLabel)}

${escapeHtml(subLabel)}
`; } else { _recipeUseNormalUnit = unit; const unitLabels = { 'pz': t('units.pz'), 'g': 'g', 'ml': 'ml' }; const unitLabel = unitLabels[unit] || unit; const inputMin = '0.1'; qtySection = `

${t('recipes.amount_label')}:

${escapeHtml(unitLabel)}
`; } // 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 ? `
⚖️ — —
${t('recipes.scale_wait_stable')}
` : ''; document.getElementById('modal-content').innerHTML = `

${escapeHtml(items[0].name)}

${recipeQty ? `

📋 ${t('recipes.recipe_qty_label')}: ${escapeHtml(recipeQty)}

` : ''}

📦 ${availInfo}

${scaleLiveSection}
${locButtons}
${qtySection}
`; document.getElementById('modal-overlay').style.display = 'flex'; syncRecipeUseQtyUnitBadge(); } 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)); } syncRecipeUseQtyUnitBadge(); } 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) { // Use the exact available qty at the selected location — do NOT send use_all=true // to the API, because that would permanently DELETE the inventory row without a // confirmation step. Instead send the precise quantity so the row is set to qty=0 // and the normal "finished items" banner can handle the reconciliation. const cachedItems = _recipeUseContext.items || []; const locItems = cachedItems.filter(i => i.location === location && parseFloat(i.quantity) > 0); qty = locItems.reduce((s, i) => s + parseFloat(i.quantity || 0), 0) || 0; if (qty <= 0) { // Nothing at this location — fallback to current input value qty = parseFloat(document.getElementById('ruse-quantity').value) || 1; } } 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, 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 → shopping prompt, then offer move const moveCallback = result.remaining > 0 ? () => setTimeout(() => { // Get vacuum state from the actual inventory item at this location const cachedItems = _recipeUseContext?.items || []; const itemAtLoc = cachedItems.find(i => i.location === location); const wasVacuum = !!(itemAtLoc?.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) { // Set context for recording the choice _pendingMoveCtx = { productId, fromLoc, openedId }; // If a preference exists, skip the modal entirely const prefMoveLoc = _getPreferredMoveLoc(productId, fromLoc); if (prefMoveLoc) { if (prefMoveLoc === fromLoc) { closeModal(); } else { confirmRecipeMove(productId, fromLoc, prefMoveLoc, openedId, wasVacuum); } _pendingMoveCtx = null; return; } const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc); const locButtons = otherLocs.map(([k, v]) => `` ).join(''); const vacuumRow = ` `; document.getElementById('modal-content').innerHTML = `

${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}

${locButtons}
${vacuumRow}
`; document.getElementById('modal-overlay').style.display = 'flex'; startMoveModalCountdown('btn-move-stay', () => { _recipeMoveCancelStay(productId, fromLoc, openedId || 0); }); } function _recipeMoveCancelStay(productId, fromLoc, openedId) { _recordMoveLocChoice(productId, fromLoc, fromLoc); _pendingMoveCtx = null; closeModal(); } async function confirmRecipeMove(productId, fromLoc, toLoc, openedId, forcedVacuum) { clearMoveModalTimer(); _recordMoveLocChoice(productId, fromLoc, toLoc); _pendingMoveCtx = null; const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0); closeModal(); try { const invData = await api('inventory_list'); const invRows = invData.inventory || []; if (openedId) { const item = invRows.find(i => i.id == openedId); const product = { name: item?.name || '', category: item?.category || '' }; const payload = { id: openedId, location: toLoc, product_id: productId, vacuum_sealed: newVacuum, }; if (!item?.expiry_user_set) { let days = estimateExpiryDays(product, toLoc); if (newVacuum) days = getVacuumExpiryDays(days); payload.expiry_date = addDays(days); } await api('inventory_update', {}, 'POST', payload); } else { const item = invRows.find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0); if (item) { const payload = { id: item.id, location: toLoc, product_id: productId, vacuum_sealed: newVacuum, }; if (!item.expiry_user_set) { let days = estimateExpiryDays({ name: item.name || '', category: item.category || '' }, toLoc); if (newVacuum) days = getVacuumExpiryDays(days); payload.expiry_date = addDays(days); } await api('inventory_update', {}, 'POST', payload); } } showToast(t('move.moved_simple', { location: LOCATIONS[toLoc]?.label || toLoc }), 'success'); } catch (e) { console.error('Recipe move error:', e); } } /** * Extract tools/appliances from recipe steps text when tools_needed is absent (old cached recipes). * Returns an array of localised tool names found in the steps. */ function _extractToolsFromSteps(steps) { const text = (steps || []).join(' ').toLowerCase(); // Map: regex keyword → display name per language const patterns = [ { re: /\bforn[oi]\b|oven|backofen/, it: 'Forno', en: 'Oven', de: 'Backofen' }, { re: /\bmicroond[ea]\b|microwave|mikrowelle/, it: 'Microonde', en: 'Microwave', de: 'Mikrowelle' }, { re: /\bfrullator[ei]\b|blender|mixer\b|pimer|frullatore a immersione|stabmixer/, it: 'Frullatore', en: 'Blender', de: 'Mixer' }, { re: /\bfritteuse\b|friggitrici[ae]\b|air\s*fry|friggitric[ae]\b|friggi\b/, it: 'Friggitrice', en: 'Air fryer', de: 'Fritteuse' }, { re: /\bpentola\s+a\s+pressione\b|pressure\s+cook|schnellkochtopf|cookeo|instant\s*pot/, it: 'Pentola a pressione', en: 'Pressure cooker', de: 'Schnellkochtopf' }, { re: /\bbimby\b|thermomix\b|monsieur\s+cuisine/,it: 'Bimby/Thermomix', en: 'Thermomix', de: 'Thermomix' }, { re: /\bimpastatric[ae]\b|planetari[ao]\b|stand\s*mixer|knetmaschine/, it: 'Impastatrice', en: 'Stand mixer', de: 'Knetmaschine' }, { re: /\bvapore\b|steamer\b|dampfgarer\b/, it: 'Vaporiera', en: 'Steamer', de: 'Dampfgarer' }, { re: /\bslow\s*cook|cottura\s+lenta\b|schongarer/, it: 'Slow cooker', en: 'Slow cooker', de: 'Schongarer' }, { re: /\bgrill[eo]?\b|griglia\b|grillpfanne/, it: 'Griglia', en: 'Grill', de: 'Grill' }, { re: /\bmacchina\s+del\s+pane\b|bread\s*machine|brotbackautomat/, it: 'Macchina del pane', en: 'Bread machine', de: 'Brotbackautomat' }, { re: /\bessiccator[ei]\b|dehydrator\b|dörrgerät/, it: 'Essiccatore', en: 'Dehydrator', de: 'Dörrgerät' }, ]; const lang = _currentLang || 'it'; const found = []; for (const p of patterns) { if (p.re.test(text)) found.push(p[lang] || p.it); } return found; } // ===== RECIPE FAVORITES & PORTION RESCALER ===== let _recipeBasePersons = 1; let _recipeCurrentPersons = 1; /** * Toggle favorite status for the currently displayed archived recipe (#124). */ async function toggleRecipeFavorite(btn) { if (!_cachedRecipe || !_cachedRecipe.id) return; const res = await api('recipes_toggle_favorite', {}, 'POST', { id: _cachedRecipe.id }); if (!res.success) return; _cachedRecipe.is_favorite = res.is_favorite; btn.classList.toggle('active', res.is_favorite); btn.textContent = res.is_favorite ? '★' : '☆'; btn.title = res.is_favorite ? t('recipes.unfavorite') : t('recipes.favorite'); // Invalidate archive cache so the star shows on next open _recipeArchiveCache = null; } /** * Scale recipe ingredient quantities (#123). * Delta: +1 or -1. Min 1, max 20 persons. */ function scaleRecipePersons(delta) { const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta)); if (newPersons === _recipeCurrentPersons) return; _recipeCurrentPersons = newPersons; const display = document.getElementById('recipe-persons-display'); if (display) display.textContent = `👥 ${newPersons} ${t('recipes.persons_short')}`; const ratio = _recipeBasePersons > 0 ? (newPersons / _recipeBasePersons) : 1; document.querySelectorAll('#recipe-content .recipe-ingredient').forEach(li => { const baseQty = parseFloat(li.dataset.baseQty || '0'); const baseStr = li.dataset.baseQtyStr || ''; const qtySpan = li.querySelector('.recipe-ing-qty'); if (!qtySpan) return; if (baseQty > 0) { // Extract unit suffix from baseStr: e.g. "200 g" → "g", "2 uova" → "uova" const m = baseStr.match(/^(\d+(?:[.,]\d+)?)\s*(.*)/); const unitSuffix = m ? m[2].trim() : ''; const scaled = baseQty * ratio; // Round sensibly: integers for whole counts, 1 decimal for fractional const rounded = scaled < 10 ? (Math.round(scaled * 10) / 10) : Math.round(scaled); qtySpan.textContent = unitSuffix ? `${rounded} ${unitSuffix}` : String(rounded); } }); _updateRecipeStockHintsAfterScale(ratio); } async function addRecipeShoppingSuggestions() { const items = (_recipeShoppingSuggestions || []).filter(s => s && s.name); if (!items.length) return; try { const payload = { items: items.map(s => ({ name: s.name, specification: s.qty ? `Da ricetta · ${s.qty}` : 'Da ricetta', })), listUUID: typeof shoppingListUUID !== 'undefined' ? shoppingListUUID : undefined, }; const data = await api('shopping_add', {}, 'POST', payload); if (data.success) { showToast(t('recipes.shopping_suggestions_added'), 'success'); if (typeof loadShoppingCount === 'function') loadShoppingCount(); } else { showToast(data.error || t('error.bring_add'), 'error'); } } catch (e) { console.error('addRecipeShoppingSuggestions:', e); showToast(t('error.bring_add'), 'error'); } } async function renderRecipe(r) { await enrichRecipeIngredientsStock(r); // Reset regen choice panel (hide choice, show button) const regenChoice = document.getElementById('recipe-regen-choice'); const regenBtn = document.getElementById('recipe-regen-btn'); if (regenChoice) regenChoice.style.display = 'none'; if (regenBtn) regenBtn.style.display = ''; // Store base persons for the rescaler (#123) _recipeBasePersons = r.persons || 1; _recipeCurrentPersons = _recipeBasePersons; const isFav = !!(_cachedRecipe && _cachedRecipe.is_favorite); let html = `

${escapeHtml(r.title)}

`; // Meta tags + star (#124) + persons rescaler (#123) html += '
'; if (r.meal) html += `${_mealLabel(r.meal)}`; html += ` 👥 ${r.persons} ${t('recipes.persons_short')} `; if (r.prep_time) html += `🔪 ${r.prep_time}`; if (r.cook_time) html += `🔥 ${r.cook_time}`; if (r.tags) r.tags.forEach(tag => { html += `${escapeHtml(tag)}`; }); // Favorite star button (#124) — visible only for archived recipes (have an id) if (_cachedRecipe && _cachedRecipe.id) { html += ``; } html += '
'; // Expiry note if (r.expiry_note) { html += `
⚠️ ${escapeHtml(r.expiry_note)}
`; } // Tools/appliances banner (shown only when specific equipment is needed) const tools = (r.tools_needed && r.tools_needed.length > 0) ? r.tools_needed.filter(t => t && t.trim()) : _extractToolsFromSteps(r.steps); if (tools.length > 0) { html += `
🔧 ${escapeHtml(t('recipes.tools_title'))}: ${tools.map(tool => `${escapeHtml(tool)}`).join('')}
`; } // Optional shopping suggestions (ingredients removed because not in pantry) const shopSug = r.shopping_suggestions || []; if (shopSug.length > 0) { const items = shopSug.map(s => `
  • ${escapeHtml(s.name)}${s.qty ? ': ' + escapeHtml(s.qty) : ''}
  • `).join(''); html += `

    🛒 ${escapeHtml(t('recipes.shopping_suggestions_intro'))}

    `; _recipeShoppingSuggestions = shopSug; } else { _recipeShoppingSuggestions = []; } // Ingredients html += `

    ${t('recipes.ingredients_title')}

    '; // Cooking mode action between ingredients and steps html += ``; // Steps html += `

    ${t('recipes.steps_title')}

      `; (r.steps || []).forEach(step => { const appliance = _stepAppliance(step); html += `
    1. ${escapeHtml(_stepStr(step))}${appliance ? ` ${escapeHtml(appliance)}` : ''}
    2. `; }); html += '
    '; // Nutritional values grid if (r.nutrition && (r.nutrition.kcal || r.nutrition.protein_g || r.nutrition.carbs_g || r.nutrition.fat_g)) { const n = r.nutrition; html += `

    📊 ${t('recipes.nutrition_title')}

    🔥 ${n.kcal ?? '—'} ${t('recipes.nutrition_kcal')}
    🥩 ${n.protein_g ?? '—'} g ${t('recipes.nutrition_protein')}
    🍞 ${n.carbs_g ?? '—'} g ${t('recipes.nutrition_carbs')}
    🫒 ${n.fat_g ?? '—'} g ${t('recipes.nutrition_fat')}

    ${t('recipes.nutrition_per_serving')}

    `; } // Storage info if (r.storage && (r.storage.where || r.storage.tips)) { const s = r.storage; const daysLabel = s.days > 0 ? t('recipes.storage_days').replace('{n}', s.days) : t('recipes.storage_immediately'); html += `

    📦 ${t('recipes.storage_title')}

    ${s.where ? `${escapeHtml(s.where)}` : ''} ${s.days > 0 ? `${escapeHtml(daysLabel)}` : `${escapeHtml(daysLabel)}`}
    ${s.tips ? `

    ${escapeHtml(s.tips)}

    ` : ''}
    `; } // Nutrition note (legacy / AI extra note) if (r.nutrition_note) { html += `

    💡 ${escapeHtml(r.nutrition_note)}

    `; } 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 // Safely extract step text regardless of whether it's a string or an object. // Also handles JSON-encoded step objects emitted by older AI generations // (e.g. {"instruction":"…","appliance_function":"…"}). const _stepStr = s => { if (typeof s === 'string' && s.trimStart().startsWith('{')) { try { s = JSON.parse(s); } catch(e) {} } const text = (s !== null && typeof s === 'object') ? (s.instruction ?? s.text ?? s.description ?? s.step ?? '') : (s ?? ''); return String(text).replace(/^Passo\s*\d+\s*[:.]\s*/i, '').replace(/^Step\s*\d+\s*[:.]\s*/i, ''); }; // Returns the appliance/function hint for a step, or null if absent/Nessuno. const _stepAppliance = s => { if (typeof s === 'string' && s.trimStart().startsWith('{')) { try { s = JSON.parse(s); } catch(e) {} } if (s !== null && typeof s === 'object' && s.appliance_function) { const a = s.appliance_function.trim(); if (a && a.toLowerCase() !== 'nessuno' && a.toLowerCase() !== 'none') return a; } return null; }; let _cookingWheelBound = false; let _cookingWheelTouchStartY = null; let _cookingWheelLastNavTs = 0; let _cookingWheelLastDelta = 0; let _cookingWheelTiltResetTimer = null; function _layoutCookingWheelCards() { const wheelEl = document.getElementById('cooking-wheel'); const centerEl = document.getElementById('cooking-step-text'); const prevEl = document.getElementById('cooking-step-prev'); const nextEl = document.getElementById('cooking-step-next'); if (!wheelEl || !centerEl || !prevEl || !nextEl) return; const wheelH = wheelEl.clientHeight; if (!wheelH) return; const centerH = centerEl.offsetHeight; const centerTop = Math.max(0, (wheelH - centerH) / 2); const centerBottom = centerTop + centerH; const pad = 8; const gap = Math.max(10, Math.round(wheelH * 0.045)); const placeGhost = (el, isPrev) => { el.style.bottom = 'auto'; if (el.classList.contains('is-empty')) { el.style.maxHeight = '0px'; return; } // Measure natural height before clamping to available slot. el.style.maxHeight = 'none'; const naturalH = Math.min(el.scrollHeight + 10, Math.round(wheelH * 0.42)); const available = isPrev ? (centerTop - gap - pad) : (wheelH - centerBottom - gap - pad); if (available <= 20) { el.style.maxHeight = '0px'; el.style.opacity = '0'; return; } const ghostH = Math.max(28, Math.min(naturalH, available)); el.style.maxHeight = `${Math.round(ghostH)}px`; el.style.opacity = ''; const top = isPrev ? Math.max(pad, centerTop - gap - ghostH) : Math.min(wheelH - pad - ghostH, centerBottom + gap); el.style.top = `${Math.round(top)}px`; }; placeGhost(prevEl, true); placeGhost(nextEl, false); } 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(); _dismissedZeroWasteTips = new Set(); clearAllCookingTimers(); } _cookingTTS = true; document.getElementById('cooking-title').textContent = _cookingRecipe.title || ''; document.getElementById('cooking-tts-btn').textContent = '🔊'; // Unlock the AudioContext now while we have a user gesture (the Start button tap) _ensureAudioUnlocked(); // Tools bar const toolsBar = document.getElementById('cooking-tools-bar'); if (toolsBar) { const tools = (_cookingRecipe.tools_needed && _cookingRecipe.tools_needed.length > 0) ? _cookingRecipe.tools_needed.filter(t => t && t.trim()) : _extractToolsFromSteps(_cookingRecipe.steps); if (tools.length > 0) { toolsBar.innerHTML = '🔧 ' + tools.map(t => `${t}`).join(''); toolsBar.style.display = ''; } else { toolsBar.style.display = 'none'; toolsBar.innerHTML = ''; } } document.getElementById('cooking-overlay').style.display = 'flex'; document.body.classList.add('cooking-mode-active'); // Hide kiosk overlay — it lives outside with z-index:2147483647 and would overlap cooking UI const _kioskOvl = document.getElementById('_kiosk_overlay'); if (_kioskOvl) _kioskOvl.style.display = 'none'; _bindCookingWheelControls(); const wheelEl = document.getElementById('cooking-wheel'); if (wheelEl) setTimeout(() => wheelEl.focus(), 20); try { screen.orientation?.lock('portrait').catch(() => {}); } catch (_) { /* ignore */ } renderCookingStep(); if (_cookingTTS) { const text = _stepStr((_cookingRecipe.steps || [])[_cookingStep]); speakCookingStep(text); } } function closeCookingMode() { document.getElementById('cooking-overlay').style.display = 'none'; document.body.classList.remove('cooking-mode-active'); // Restore kiosk overlay const _kioskOvl = document.getElementById('_kiosk_overlay'); if (_kioskOvl) _kioskOvl.style.display = 'flex'; // 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; _cookingWheelLastDelta = 0; _cookingVisited = new Set(); _dismissedZeroWasteTips = new Set(); clearAllCookingTimers(); renderCookingStep(); } function _setCookingWheelTilt(clientX, clientY) { const wheelEl = document.getElementById('cooking-wheel'); if (!wheelEl) return; const rect = wheelEl.getBoundingClientRect(); if (!rect.width || !rect.height) return; const nx = ((clientX - rect.left) / rect.width) - 0.5; const ny = ((clientY - rect.top) / rect.height) - 0.5; const tiltY = Math.max(-1, Math.min(1, nx)) * 7; const tiltX = Math.max(-1, Math.min(1, -ny)) * 4; const glow = 0.32 + (Math.min(1, Math.abs(nx) + Math.abs(ny)) * 0.45); wheelEl.style.setProperty('--wheel-tilt-x', `${tiltX.toFixed(2)}deg`); wheelEl.style.setProperty('--wheel-tilt-y', `${tiltY.toFixed(2)}deg`); wheelEl.style.setProperty('--wheel-glow', glow.toFixed(2)); } function _resetCookingWheelTilt() { const wheelEl = document.getElementById('cooking-wheel'); if (!wheelEl) return; wheelEl.style.setProperty('--wheel-tilt-x', '0deg'); wheelEl.style.setProperty('--wheel-tilt-y', '0deg'); wheelEl.style.setProperty('--wheel-glow', '0.45'); } function _pulseCookingWheel() { const wheelEl = document.getElementById('cooking-wheel'); if (!wheelEl) return; wheelEl.classList.remove('snap'); void wheelEl.offsetWidth; wheelEl.classList.add('snap'); setTimeout(() => wheelEl.classList.remove('snap'), 320); } function _cookingStepFeedback() { _pulseCookingWheel(); if (navigator.vibrate) { try { navigator.vibrate([10, 16, 10]); } catch (_) { /* ignore */ } } } function _bindCookingWheelControls() { const wheelEl = document.getElementById('cooking-wheel'); if (!wheelEl || _cookingWheelBound) return; wheelEl.addEventListener('wheel', (e) => { if (!document.body.classList.contains('cooking-mode-active')) return; if (Math.abs(e.deltaY) < 8) return; e.preventDefault(); const now = Date.now(); if (now - _cookingWheelLastNavTs < 240) return; _cookingWheelLastNavTs = now; navigateCookingStep(e.deltaY > 0 ? 1 : -1); }, { passive: false }); wheelEl.addEventListener('touchstart', (e) => { const t = e.touches && e.touches[0] ? e.touches[0] : null; _cookingWheelTouchStartY = t ? t.clientY : null; if (t) _setCookingWheelTilt(t.clientX, t.clientY); }, { passive: true }); wheelEl.addEventListener('touchmove', (e) => { const t = e.touches && e.touches[0] ? e.touches[0] : null; if (t) _setCookingWheelTilt(t.clientX, t.clientY); }, { passive: true }); wheelEl.addEventListener('touchend', (e) => { if (_cookingWheelTouchStartY === null) return; const endY = e.changedTouches && e.changedTouches[0] ? e.changedTouches[0].clientY : _cookingWheelTouchStartY; const delta = _cookingWheelTouchStartY - endY; _cookingWheelTouchStartY = null; if (Math.abs(delta) < 42) return; const now = Date.now(); if (now - _cookingWheelLastNavTs < 240) return; _cookingWheelLastNavTs = now; navigateCookingStep(delta > 0 ? 1 : -1); if (_cookingWheelTiltResetTimer) clearTimeout(_cookingWheelTiltResetTimer); _cookingWheelTiltResetTimer = setTimeout(_resetCookingWheelTilt, 80); }, { passive: true }); wheelEl.addEventListener('mousemove', (e) => { if (!document.body.classList.contains('cooking-mode-active')) return; _setCookingWheelTilt(e.clientX, e.clientY); }); wheelEl.addEventListener('mouseleave', () => { _resetCookingWheelTilt(); }); window.addEventListener('resize', () => { if (!document.body.classList.contains('cooking-mode-active')) return; _layoutCookingWheelCards(); }); wheelEl.addEventListener('keydown', (e) => { if (!document.body.classList.contains('cooking-mode-active')) return; if (e.key === 'ArrowDown') { e.preventDefault(); navigateCookingStep(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); navigateCookingStep(-1); } }); _cookingWheelBound = true; } function _animateCookingWheelTransition() { const wheelEl = document.getElementById('cooking-wheel'); if (!wheelEl) return; wheelEl.classList.remove('turn-next', 'turn-prev'); if (_cookingWheelLastDelta === 0) return; // Force style recalculation so repeated class toggles retrigger CSS animation. void wheelEl.offsetWidth; wheelEl.classList.add(_cookingWheelLastDelta > 0 ? 'turn-next' : 'turn-prev'); setTimeout(() => { wheelEl.classList.remove('turn-next', 'turn-prev'); }, 380); } function renderCookingStep() { if (!_cookingRecipe) return; const steps = _cookingRecipe.steps || []; const step = steps[_cookingStep] || ''; const cleanStep = _stepStr(step); 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; const prevEl = document.getElementById('cooking-step-prev'); const nextEl = document.getElementById('cooking-step-next'); if (prevEl) { if (_cookingStep > 0) { prevEl.textContent = _stepStr(steps[_cookingStep - 1]); prevEl.classList.remove('is-empty'); } else { prevEl.textContent = ''; prevEl.classList.add('is-empty'); } } if (nextEl) { if (_cookingStep < total - 1) { nextEl.textContent = _stepStr(steps[_cookingStep + 1]); nextEl.classList.remove('is-empty'); } else { nextEl.textContent = ''; nextEl.classList.add('is-empty'); } } requestAnimationFrame(_layoutCookingWheelCards); _animateCookingWheelTransition(); _cookingWheelLastDelta = 0; // 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 ``; }).join(''); } // Ingredients are shown in the recipe view only, not in cooking mode. const ingsEl = document.getElementById('cooking-step-ings'); if (ingsEl) { 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); // Zero-waste tip for this step _renderZeroWasteTip(_cookingStep); // TTS: auto-speak is handled by navigateCookingStep() and startCookingMode() callers. // Use replayCookingTTS() to re-read the current step manually ("Rileggi" button). } // ===== ZERO-WASTE TIPS ===== let _dismissedZeroWasteTips = new Set(); // dismissed tip indices for this cooking session function _renderZeroWasteTip(stepIdx) { const tipEl = document.getElementById('cooking-zerowaste-tip'); if (!tipEl) return; // Check setting const s = getSettings(); if (!s.zerowaste_tips_enabled) { tipEl.style.display = 'none'; return; } // Already dismissed for this step in this session if (_dismissedZeroWasteTips.has(stepIdx)) { tipEl.style.display = 'none'; return; } // Find tip for current step const tips = (_cookingRecipe && _cookingRecipe.zero_waste_tips) || []; const tip = tips.find(t => t.step === stepIdx); if (!tip) { tipEl.style.display = 'none'; return; } // Populate and show const scrapEl = document.getElementById('cooking-zerowaste-scrap'); const textEl = document.getElementById('cooking-zerowaste-text'); if (scrapEl) scrapEl.textContent = tip.scrap || ''; if (textEl) textEl.textContent = tip.tip || ''; tipEl.style.display = 'flex'; } function _dismissZeroWasteTip() { _dismissedZeroWasteTips.add(_cookingStep); const tipEl = document.getElementById('cooking-zerowaste-tip'); if (tipEl) tipEl.style.display = 'none'; } 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 }; } /** * Build a proxy request to call Home Assistant tts.speak service. * Requires HA URL, bearer token and entity_id (media player) in settings. */ function _buildHaTtsRequest(text, s) { const haUrl = (s.ha_url || '').replace(/\/$/, ''); const url = haUrl + '/api/services/tts/speak'; const headers = { 'Content-Type': 'application/json' }; const body = JSON.stringify({ entity_id: s.ha_tts_entity || '', message: text, }); return { url, method: 'POST', 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', 'X-EverShelf-Request': '1', ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}), }, 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(); // Respect the user's explicit engine choice. // Do NOT gate on s.tts_enabled — the _cookingTTS toggle in cooking mode is the only gate. // If the preferred engine fails, always fall back to browser TTS. const fallback = () => _speakBrowser(text); try { // 1. Browser engine — always use Web Speech API / kiosk bridge directly if (!s.tts_engine || s.tts_engine === 'browser') { _speakBrowser(text); // 2. HA TTS — if HA is enabled and a media player entity is configured } else if (s.ha_enabled && s.ha_tts_entity && s.ha_url) { try { const req = _buildHaTtsRequest(text, s); await _ttsViaProxy(req); } catch(e) { fallback(); } // 3. Generic external endpoint ('server' or legacy 'custom' engine) } else if ((s.tts_engine === 'server' || s.tts_engine === 'custom') && s.tts_url) { try { const req = _buildTtsRequest(text, s); await _ttsViaProxy(req); } catch(e) { fallback(); } } else { _speakBrowser(text); } } catch(e) { /* silent — TTS is non-critical */ } } function replayCookingTTS() { if (!_cookingRecipe) return; const steps = _cookingRecipe.steps || []; const text = _stepStr(steps[_cookingStep]); 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' || engine === 'custom') ? '' : 'none'; } // ===== HOME ASSISTANT PANEL ===== function onHaEnabledChange() { const enabled = document.getElementById('setting-ha-enabled')?.checked; const cfg = document.getElementById('ha-config-section'); if (cfg) cfg.style.display = enabled ? '' : 'none'; } function _applyHaSettingsUI(s) { const haEnabled = document.getElementById('setting-ha-enabled'); if (haEnabled) { haEnabled.checked = !!s.ha_enabled; onHaEnabledChange(); } const haUrl = document.getElementById('setting-ha-url'); if (haUrl) haUrl.value = s.ha_url || ''; // Never pre-fill token (write-only field); only show placeholder if already set const haTokenEl = document.getElementById('setting-ha-token'); if (haTokenEl) haTokenEl.placeholder = s.ha_token_set ? '••••••••••••' : 'eyJhbGci...'; const haEntity = document.getElementById('setting-ha-tts-entity'); if (haEntity) haEntity.value = s.ha_tts_entity || ''; const haWebhook = document.getElementById('setting-ha-webhook-id'); if (haWebhook) haWebhook.value = s.ha_webhook_id || ''; const haNotify = document.getElementById('setting-ha-notify-service'); if (haNotify) haNotify.value = s.ha_notify_service || ''; const haExpiry = document.getElementById('setting-ha-expiry-days'); if (haExpiry) haExpiry.value = s.ha_expiry_days || 3; // Checkboxes for events const events = (s.ha_webhook_events || '').split(',').map(e => e.trim()); const cbExpiry = document.getElementById('ha-event-expiry'); if (cbExpiry) cbExpiry.checked = events.includes('expiry'); const cbShopping = document.getElementById('ha-event-shopping'); if (cbShopping) cbShopping.checked = events.includes('shopping_add'); const cbStock = document.getElementById('ha-event-stock'); if (cbStock) cbStock.checked = events.includes('stock_update'); } function _loadHaTab() { const s = getSettings(); _applyHaSettingsUI(s); _renderHaSensorYaml(); } function _renderHaSensorYaml() { const el = document.getElementById('ha-sensor-yaml'); if (!el) return; const base = (window.location.origin + window.location.pathname).replace(/\/$/, '').replace(/\/index\.html$/, ''); el.textContent = `# Add to configuration.yaml (Home Assistant) # Restart HA after editing. sensor: - platform: rest name: "EverShelf Overview" unique_id: evershelf_overview resource: "${base}/api/?action=ha_sensor" scan_interval: 300 value_template: "{{ value_json.state }}" json_attributes: - expiring_soon - expiring_3d - expired_items - total_items - shopping_items - expiring_list - expired_list - low_stock_list - next_expiry_name - next_expiry_date - days_to_next_expiry - last_updated unit_of_measurement: "items" device_class: null - platform: rest name: "EverShelf Expired Items" unique_id: evershelf_expired resource: "${base}/api/?action=ha_sensor&sensor=expired" scan_interval: 600 value_template: "{{ value_json.state }}" unit_of_measurement: "items" - platform: rest name: "EverShelf Shopping Count" unique_id: evershelf_shopping resource: "${base}/api/?action=ha_sensor&sensor=shopping" scan_interval: 180 value_template: "{{ value_json.state }}" unit_of_measurement: "items" # Full product inventory — all items with complete details - platform: rest name: "EverShelf Products" unique_id: evershelf_products resource: "${base}/api/?action=ha_sensor&sensor=product" scan_interval: 600 value_template: "{{ value_json.state }}" json_attributes: - items - last_updated unit_of_measurement: "items"`; } function copyHaSensorYaml() { const el = document.getElementById('ha-sensor-yaml'); if (!el) return; navigator.clipboard.writeText(el.textContent).then(() => { showToast(t('settings.ha.sensor_copied')); }).catch(() => { showToast(t('error.copy_failed')); }); } async function testHaConnection() { const statusEl = document.getElementById('ha-test-status'); const haUrl = document.getElementById('setting-ha-url')?.value.trim(); const haToken = document.getElementById('setting-ha-token')?.value.trim(); const s = getSettings(); const tokenToUse = haToken || (s.ha_token_set ? '__server__' : ''); if (!haUrl) { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = t('settings.ha.error_no_url'); } return; } if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = t('settings.ha.testing'); } try { const result = await api('ha_test', {}, 'POST', { url: haUrl, token: tokenToUse }); if (result.ok) { if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = '✅ ' + t('settings.ha.test_ok').replace('{version}', result.version || 'HA'); } } else { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = '❌ ' + t('settings.ha.test_fail').replace('{error}', result.error || result.http_code || ''); } } } catch(e) { if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = '❌ ' + e.message; } } } function applyHaTtsPreset() { const s = getSettings(); const haUrl = (document.getElementById('setting-ha-url')?.value || s.ha_url || '').replace(/\/$/, ''); const entity = document.getElementById('setting-ha-tts-entity')?.value || s.ha_tts_entity || ''; if (!haUrl) { showToast(t('settings.ha.error_no_url')); return; } // Switch to TTS tab and fill fields const ttsTab = document.querySelector('[data-tab="tab-tts"]'); if (ttsTab) ttsTab.click(); const engineEl = document.getElementById('setting-tts-engine'); if (engineEl) { engineEl.value = 'server'; onTtsEngineChange('server'); } const urlEl = document.getElementById('setting-tts-url'); if (urlEl) urlEl.value = haUrl + '/api/services/tts/speak'; const methodEl = document.getElementById('setting-tts-method'); if (methodEl) methodEl.value = 'POST'; const authTypeEl = document.getElementById('setting-tts-auth-type'); if (authTypeEl) { authTypeEl.value = 'bearer'; onTtsAuthTypeChange('bearer'); } const tokenEl = document.getElementById('setting-tts-token'); if (tokenEl) { const haToken = document.getElementById('setting-ha-token')?.value.trim() || s.ha_token || ''; tokenEl.value = haToken; } const payloadKeyEl = document.getElementById('setting-tts-payload-key'); if (payloadKeyEl) payloadKeyEl.value = 'message'; const ctEl = document.getElementById('setting-tts-content-type'); if (ctEl) ctEl.value = 'application/json'; const extraEl = document.getElementById('setting-tts-extra-fields'); if (extraEl) extraEl.value = entity ? JSON.stringify({ entity_id: entity }) : ''; showToast(t('settings.ha.tts_preset_applied')); } function showHaWebhookHelp() { const msg = t('settings.ha.webhook_help'); showToast(msg, 8000); } async function saveHaSettings() { const s = getSettings(); const haEnabled = document.getElementById('setting-ha-enabled')?.checked || false; const haUrl = document.getElementById('setting-ha-url')?.value.trim() || ''; const haToken = document.getElementById('setting-ha-token')?.value.trim() || ''; const haTtsEntity = document.getElementById('setting-ha-tts-entity')?.value.trim() || ''; const haWebhookId = document.getElementById('setting-ha-webhook-id')?.value.trim() || ''; const haNotify = document.getElementById('setting-ha-notify-service')?.value.trim() || ''; const haExpiryDays = parseInt(document.getElementById('setting-ha-expiry-days')?.value, 10) || 3; const events = []; if (document.getElementById('ha-event-expiry')?.checked) events.push('expiry'); if (document.getElementById('ha-event-shopping')?.checked) events.push('shopping_add'); if (document.getElementById('ha-event-stock')?.checked) events.push('stock_update'); const haEvents = events.join(','); s.ha_enabled = haEnabled; s.ha_url = haUrl; if (haToken) s.ha_token = haToken; s.ha_tts_entity = haTtsEntity; s.ha_webhook_id = haWebhookId; s.ha_webhook_events = haEvents; s.ha_notify_service = haNotify; s.ha_expiry_days = haExpiryDays; saveSettingsToStorage(s); const statusEl = document.getElementById('ha-save-status'); try { const settingsToken = document.getElementById('setting-settings-token')?.value.trim() || (typeof getApiToken === 'function' ? getApiToken() : ''); if (settingsToken && typeof setApiToken === 'function') setApiToken(settingsToken); const tokenHeader = settingsToken ? { 'X-API-Token': settingsToken } : (typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}); const result = await api('save_settings', {}, 'POST', { ha_enabled: haEnabled, ha_url: haUrl, ...(haToken ? { ha_token: haToken } : {}), ha_tts_entity: haTtsEntity, ha_webhook_id: haWebhookId, ha_webhook_events: haEvents, ha_notify_service: haNotify, ha_expiry_days: haExpiryDays, }, tokenHeader); if (statusEl) { statusEl.style.display = 'block'; statusEl.className = result.success ? 'settings-status success' : 'settings-status error'; statusEl.textContent = result.success ? '✅ ' + t('settings.saved') : '❌ ' + (result.error || t('settings.saved_local_error')); setTimeout(() => { if (statusEl) statusEl.style.display = 'none'; }, 4000); } } catch(e) { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '✅ ' + t('settings.saved_local'); setTimeout(() => { if (statusEl) statusEl.style.display = 'none'; }, 4000); } } } /** 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 = ''; return; } if (!window.speechSynthesis) { sel.innerHTML = ``; return; } // Reset to loading state each time (settings page may be re-opened) sel.innerHTML = ``; const populate = () => { let voices = []; try { voices = (window.speechSynthesis.getVoices() || []).filter(v => { try { return v != null && typeof v.lang === 'string' && v.lang.length > 0; } catch (_) { return false; } }); } catch (_) { return false; } 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 => `` ).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 = ``; } } }, 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; const _doSpeak = () => { window.speechSynthesis.cancel(); const utt = new SpeechSynthesisUtterance(text); utt.rate = rate; utt.pitch = pitch; const voices = window.speechSynthesis.getVoices(); // 1. User-selected voice by name const preferred = s.tts_voice ? voices.find(v => v.name === s.tts_voice) : null; if (preferred) { utt.voice = preferred; utt.lang = preferred.lang; } else { // Prefer offline (localService) voices to avoid silent failure when no internet. // Priority: local Italian → any Italian → local any-lang → first available → lang-only const itLocal = voices.find(v => v.lang && v.lang.startsWith('it') && v.localService); const itCloud = voices.find(v => v.lang && v.lang.startsWith('it')); const anyLocal = voices.find(v => v.localService); const chosen = itLocal || itCloud || anyLocal || voices[0]; if (chosen) { utt.voice = chosen; utt.lang = chosen.lang; } else { // No voices loaded yet — set lang and let the browser decide utt.lang = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-US' : 'it-IT'; } } // Chrome quirks: // 1. cancel() + immediate speak() is silently dropped → 50 ms gap fixes it // 2. speechSynthesis gets paused after tab backgrounding; cancel() does NOT // clear the paused state — need an explicit resume() before speak() setTimeout(() => { if (window.speechSynthesis.paused) window.speechSynthesis.resume(); window.speechSynthesis.speak(utt); }, 50); }; // If voices haven't loaded yet (async in Chrome/Android), wait once then speak if (!window.speechSynthesis.getVoices().length) { const _onReady = () => { window.speechSynthesis.onvoiceschanged = null; _doSpeak(); }; window.speechSynthesis.onvoiceschanged = _onReady; // Safety timeout: fire anyway after 500 ms if onvoiceschanged never fires setTimeout(() => { if (window.speechSynthesis.onvoiceschanged === _onReady) { window.speechSynthesis.onvoiceschanged = null; _doSpeak(); } }, 500); } else { _doSpeak(); } } function testSound() { const statusEl = document.getElementById('tts-test-status'); _ensureAudioUnlocked(); _playCookingTimerSound('done'); if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '🔔 Suono inviato — hai sentito un beep?'; } } 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') { // Diagnostic: check if Android TTS engine is ready const ready = typeof _kioskBridge.isTtsReady === 'function' ? _kioskBridge.isTtsReady() : 'unknown'; if (ready === 'false') { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Android TTS non inizializzato — riavvia l\'app kiosk o installa un motore TTS dal Play Store.'; } return; } 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); if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = '⏳ Invio al motore TTS Android...'; } // Register callbacks: Android will call these after speak completes/fails let _ttsTestTimer = null; window._kioskTtsDone = (uid) => { clearTimeout(_ttsTestTimer); window._kioskTtsDone = null; window._kioskTtsError = null; if (statusEl) { statusEl.className = 'settings-status success'; statusEl.textContent = '✅ ' + t('settings.tts.test_ok_kiosk'); } }; window._kioskTtsError = (uid, code) => { clearTimeout(_ttsTestTimer); window._kioskTtsDone = null; window._kioskTtsError = null; const msg = code == -1 ? 'sintesi non riuscita' : code == -2 ? 'lingua non supportata' : code == -3 ? 'servizio non disponibile' : ('codice ' + code); if (statusEl) { statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Errore TTS Android (' + msg + ') — installa o aggiorna Google Text-to-Speech dal Play Store.'; } }; // Timeout: if Android doesn't callback within 10s, ask user if they heard the voice // (speech can take 6-8 s; UtteranceProgressListener may not fire on all firmware) _ttsTestTimer = setTimeout(() => { window._kioskTtsDone = null; window._kioskTtsError = null; if (!statusEl) return; statusEl.className = 'settings-status'; statusEl.style.display = 'block'; statusEl.innerHTML = '🔊 ' + t('settings.tts.heard_question') + '
    ' + '
    ' + '' + '' + '
    '; window._ttsTestYes = () => { window._ttsTestYes = null; window._ttsTestNo = null; if (statusEl) { statusEl.className = 'settings-status success'; statusEl.innerHTML = '✅ ' + t('settings.tts.test_ok'); } }; window._ttsTestNo = () => { window._ttsTestYes = null; window._ttsTestNo = null; if (statusEl) { statusEl.className = 'settings-status error'; statusEl.innerHTML = '❌ ' + t('settings.tts.test_fail_steps'); } }; }, 10000); _speakBrowser('Test vocale EverShelf. La sintesi vocale funziona correttamente.'); 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; } // ── Audio beep test (AudioContext — works even if TTS is broken) ───── _ensureAudioUnlocked(); _playCookingTimerSound('done'); // 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); // Diagnostic: surface problems before attempting TTS const voices = window.speechSynthesis.getVoices(); if (!voices.length) { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Nessuna voce disponibile — installa un pacchetto vocale nelle impostazioni di sistema.'; } return; } // Warn if only cloud voices are available (won't work offline) const itLocal = voices.find(v => v.lang && v.lang.startsWith('it') && v.localService); const anyLocal = voices.find(v => v.localService); if (!itLocal && !anyLocal) { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Solo voci cloud disponibili — la sintesi vocale offline richiede una voce locale installata sul dispositivo (es. Google Text-to-Speech → Scarica voci offline).'; } return; } // onerror callback: update status if speak() fails const _ttsErrHandler = (evt) => { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Errore TTS: ' + (evt.error || 'sconosciuto') + ' — prova a riavviare il browser o a cambiare voce.'; } }; // Temporarily hook onerror via a custom utterance const testUtt = new SpeechSynthesisUtterance('Test vocale EverShelf. La sintesi vocale funziona correttamente.'); testUtt.rate = s.tts_rate; testUtt.pitch = s.tts_pitch; const chosenVoice = s.tts_voice ? voices.find(v => v.name === s.tts_voice) : null; const fallbackVoice = itLocal || voices.find(v => v.lang && v.lang.startsWith('it')) || anyLocal || voices[0]; const testVoice = chosenVoice || fallbackVoice; if (testVoice) { testUtt.voice = testVoice; testUtt.lang = testVoice.lang; } testUtt.onerror = _ttsErrHandler; testUtt.onstart = () => { if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status success'; statusEl.textContent = '✅ Voce attiva: ' + (testVoice ? testVoice.name + ' (' + testVoice.lang + (testVoice.localService ? ', offline' : ', cloud') + ')' : 'default'); } }; window.speechSynthesis.cancel(); setTimeout(() => { if (window.speechSynthesis.paused) window.speechSynthesis.resume(); window.speechSynthesis.speak(testUtt); // If onstart doesn't fire within 2s, show a warning setTimeout(() => { if (statusEl && statusEl.className.includes('success')) return; // already started if (!statusEl?.className.includes('error')) { statusEl.style.display = 'block'; statusEl.className = 'settings-status error'; statusEl.textContent = '❌ Nessuna risposta dalla voce — se il beep era udibile, il TTS è bloccato. Prova a ricaricare la pagina o a cambiare voce.'; } }, 2000); }, 50); if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = '🔊 Beep + TTS in corso...'; } 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 = t('settings.tts.url_missing'); } return; } if (statusEl) { statusEl.style.display = 'block'; statusEl.className = 'settings-status'; statusEl.textContent = t('settings.tts.test_sending'); } 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 = t('settings.tts.test_ok').replace('{code}', httpCode); } } 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 = `❌ ${t('error.prefix')}: ${e.message}`; } } } // ===== COOKING TIMER SYSTEM ===== let _cookingTimers = []; // { id, label, total, seconds, running, interval } let _cookingTimerIdCounter = 0; let _cookingSuggestedSeconds = 0; let _cookingSuggestedLabel = ''; let _sharedAudioCtx = null; // pre-unlocked AudioContext (created on user gesture) /** * Pre-unlock the shared AudioContext during a user gesture. * Call this from any click/touch handler so that the context is already * in 'running' state when the timer fires (potentially outside a gesture). */ function _ensureAudioUnlocked() { try { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return; if (!_sharedAudioCtx || _sharedAudioCtx.state === 'closed') { _sharedAudioCtx = new Ctx(); } if (_sharedAudioCtx.state === 'suspended') { _sharedAudioCtx.resume().catch(() => {}); } } catch (_) { /* ignore */ } } function _playCookingTimerSound(type = 'done') { try { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return; // Use the pre-unlocked shared context; fall back to a new one if closed let ctx = (_sharedAudioCtx && _sharedAudioCtx.state !== 'closed') ? _sharedAudioCtx : null; if (!ctx) { ctx = new Ctx(); } const pattern = type === 'warning' ? [{ f: 880, d: 0.08, o: 0.00 }, { f: 1046, d: 0.10, o: 0.14 }] : [ { f: 740, d: 0.10, o: 0.00 }, { f: 988, d: 0.12, o: 0.18 }, { f: 1318, d: 0.14, o: 0.38 } ]; const doPlay = () => { const now = ctx.currentTime; for (const p of pattern) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = p.f; gain.gain.setValueAtTime(0.0001, now + p.o); gain.gain.exponentialRampToValueAtTime(0.12, now + p.o + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, now + p.o + p.d); osc.connect(gain); gain.connect(ctx.destination); osc.start(now + p.o); osc.stop(now + p.o + p.d + 0.02); } }; if (ctx.state === 'suspended') { ctx.resume().then(doPlay).catch(() => {}); } else { doPlay(); } } catch (_) { /* ignore */ } } function _notifyCookingTimer(type, label) { const key = type === 'warning' ? 'cooking.timer_warning_tts' : 'cooking.timer_expired_tts'; const msg = t(key).replace('{label}', label || t('cooking.timer')); // Always play the beep (uses pre-unlocked shared AudioContext) _playCookingTimerSound(type === 'warning' ? 'warning' : 'done'); // Timer alerts always speak — they are alarms, not step narration. // Do NOT gate on _cookingTTS; that toggle is for step-by-step reading only. // Also include the kiosk native TTS bridge which works even when // window.speechSynthesis is absent on older Android WebView. const s = getSettings(); const hasBrowserTts = typeof window !== 'undefined' && 'speechSynthesis' in window; const hasCustomTts = s.tts_engine === 'custom' && !!s.tts_url; const hasKioskTts = typeof _kioskBridge !== 'undefined' && typeof _kioskBridge.speak === 'function'; if (hasBrowserTts || hasCustomTts || hasKioskTts) { speakCookingStep(msg); } } /** * 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 raw = String(text || ''); 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','piu','meno','tutto','tutta', 'the','and','for','mit','und','zum','zur']); const applianceWords = new Set(['moulinex','cookeo','bimby','forno','airfryer','friggitrice','microonde','tm5','tm6']); 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 = raw.length; for (const p of timePatterns) { const r = p.exec(raw); if (r && r.index < timeIdx) timeIdx = r.index; } let beforeTime = (raw.slice(0, timeIdx).trim() || raw) .replace(/\([^)]*\)/g, ' ') .replace(/[.,!?;:'"\[\]]/g, ' ') .replace(/^\s*(poi|quindi|allora|infine|then|dann)\s+/i, '') .replace(/\s+/g, ' ') .trim(); if (!beforeTime) return t('cooking.step_fallback').replace('{n}', stepNum + 1); const actionRules = [ { re: /\b(rosolatur\w*|rosola\w*|soffrigg\w*)\b/i, label: 'Rosolatura' }, { re: /\b(stuf\w*)\b/i, label: 'Stufare' }, { re: /\b(boll\w*|sobboll\w*)\b/i, label: 'Bollitura' }, { re: /\b(cuoc\w*|cottur\w*)\b/i, label: 'Cottura' }, { re: /\b(tost\w*)\b/i, label: 'Tostatura' }, { re: /\b(mescol\w*|mischi\w*)\b/i, label: 'Mescola' }, { re: /\b(ripos\w*)\b/i, label: 'Riposo' }, { re: /\b(marin\w*)\b/i, label: 'Marinatura' }, { re: /\b(preriscald\w*|accend\w*|scald\w*)\b/i, label: 'Preriscalda' } ]; const hasAppliance = /\b(moulinex|cookeo|bimby|forno|airfryer|friggitrice|microonde|tm5|tm6)\b/i.test(beforeTime); let actionLabel = ''; for (const rule of actionRules) { if (rule.re.test(beforeTime)) { actionLabel = rule.label; break; } } // Remove the leading verb chunk and appliance references, then keep only compact object words. let objectPart = beforeTime .replace(/^(?:fai|lascia|metti|porta|tieni|poi|quindi)\s+/i, '') .replace(/^(?:rosola\w*|soffrigg\w*|stuf\w*|boll\w*|sobboll\w*|cuoc\w*|tost\w*|mescol\w*|mischi\w*|ripos\w*|marin\w*|preriscald\w*|accend\w*|scald\w*)\s+/i, '') .replace(/\b(?:nel|nella|nello|nei|in|su|sul|sulla|dentro|con)\b\s+(?:il|lo|la|i|gli|le)?\s*(?:moulinex|cookeo|bimby|forno|airfryer|friggitrice|microonde|tm5|tm6)\b/gi, ' ') .replace(/\b(moulinex|cookeo|bimby|forno|airfryer|friggitrice|microonde|tm5|tm6)\b/gi, ' ') .replace(/\s+/g, ' ') .trim(); const objectWords = objectPart .split(/\s+/) .map(w => w.toLowerCase()) .filter(w => w.length > 2 && !/^\d+$/.test(w) && !fillers.has(w) && !applianceWords.has(w)); const shortObject = objectWords.slice(0, 2).join(' '); let label = ''; if (actionLabel) { label = shortObject ? `${actionLabel} ${shortObject}` : actionLabel; if (actionLabel === 'Preriscalda' && hasAppliance) label = 'Preriscalda'; } else { const fallback = beforeTime .split(/\s+/) .map(w => w.toLowerCase()) .filter(w => w.length > 2 && !/^\d+$/.test(w) && !fillers.has(w) && !applianceWords.has(w)) .slice(0, 3) .join(' '); label = fallback || t('cooking.step_fallback').replace('{n}', stepNum + 1); } label = label.replace(/\s+/g, ' ').trim(); if (!label) return t('cooking.step_fallback').replace('{n}', stepNum + 1); // Keep timer chips compact and readable. const maxLen = 30; if (label.length > maxLen) label = label.slice(0, maxLen).trim() + '…'; return label.charAt(0).toUpperCase() + label.slice(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) { _ensureAudioUnlocked(); // unlock AudioContext on this user gesture const id = ++_cookingTimerIdCounter; _cookingTimers.push({ id, label, total: seconds, seconds, running: false, interval: null }); renderTimersBar(); toggleCookingTimerById(id); // auto-start } function removeCookingTimer(id) { const timer = _cookingTimers.find(ti => ti.id === id); if (timer && timer.interval) clearInterval(timer.interval); _cookingTimers = _cookingTimers.filter(ti => ti.id !== id); renderTimersBar(); _updateScreenFlash(); } function toggleCookingTimerById(id) { const timer = _cookingTimers.find(ti => ti.id === id); if (!timer) return; if (timer.running) { clearInterval(timer.interval); timer.interval = null; timer.running = false; } else { timer.running = true; timer.interval = setInterval(() => { timer.seconds = Math.max(0, timer.seconds - 1); if (timer.seconds === 10) { _notifyCookingTimer('warning', timer.label); } if (timer.seconds === 0) { _cookingTimerDoneById(id); return; } _updateTimerCard(id); }, 1000); } _updateTimerCard(id); } function resetCookingTimerById(id) { const timer = _cookingTimers.find(ti => ti.id === id); if (!timer) return; clearInterval(timer.interval); timer.interval = null; timer.running = false; timer.seconds = timer.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 (!timer) return; clearInterval(timer.interval); timer.interval = null; timer.running = false; timer.seconds = 0; // Show the done state in the card before removing it _updateTimerCard(id); _updateScreenFlash(); _notifyCookingTimer('done', timer.label); // Keep the done card visible for 3 s so the user sees which timer finished setTimeout(() => removeCookingTimer(id), 3000); } 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 `
    ${escapeHtml(t.label)} ${_formatTimerDisplay(sec)}
    `; }).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 = _stepStr(steps[_cookingStep]); 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; } _cookingWheelLastDelta = delta; _cookingStep = next; renderCookingStep(); _cookingStepFeedback(); if (_cookingTTS) { const text = _stepStr((_cookingRecipe.steps || [])[_cookingStep]); 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) => `` ).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 = `${mpt.icon} ${mpt.label} ${t('meal_plan.suggested_by')}`; el.style.display = 'flex'; } if (banner) { const slotLabel = mealSlot === 'pranzo' ? '🌤️ ' + t('meal_types.pranzo') : '🌙 ' + t('meal_types.cena'); banner.innerHTML = `${slotLabel}·${mpt.icon} ${mpt.label}`; 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; } // Async: check if the required ingredient is actually in inventory. // If not, disable the chip and warn the user. _checkMealPlanIngredientAvailable(typeId).then(available => { if (!available && chipWrap && chipWrap.style.display !== 'none') { if (chipCb) { chipCb.checked = false; chipCb.disabled = true; } if (chipLabel) chipLabel.textContent = `${mpt.icon} ${mpt.label} ⚠️ ${t('meal_plan.not_available') || 'non disponibile'}`; chipWrap.style.opacity = '0.5'; if (banner) banner.style.display = 'none'; } }).catch(() => {/* ignore */}); } function showRegenChoice() { document.getElementById('recipe-regen-btn').style.display = 'none'; document.getElementById('recipe-regen-choice').style.display = ''; } function cancelRegenChoice() { document.getElementById('recipe-regen-choice').style.display = 'none'; document.getElementById('recipe-regen-btn').style.display = ''; } function doRegenerateReplace() { cancelRegenChoice(); _doRegenerate(); } async function doRegenerateSave() { if (_cachedRecipe && _cachedRecipe.recipe) { await saveRecipeToArchive(_cachedRecipe.recipe); } cancelRegenChoice(); _doRegenerate(); } function _doRegenerate() { // 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 = ''; } function regenerateRecipe() { showRegenChoice(); } 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', 'X-EverShelf-Request': '1', ...(typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {}), }, 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 (response.status === 401) { window._apiTokenRequired = true; if (typeof _promptApiTokenIfNeeded === 'function') _promptApiTokenIfNeeded(); showToast(t('startup.token_required') || 'Token API richiesto', 'warning'); } else 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) { await 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 { // Stream closed without recipe or error event — likely a server crash mid-stream showToast(t('recipes.stream_interrupted'), 'error'); } } } catch (err) { console.error('Recipe error:', err); document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-ask').style.display = ''; // Show the actual JS error (e.g. NetworkError, AbortError, TypeError) const errMsg = err?.message || String(err); showToast(`${t('error.connection')}: ${errMsg}`, '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(); } /** Returns true if a chat reply looks like it contains a recipe with ingredients */ function _looksLikeRecipe(text) { // Must have an "Ingredienti" section header AND a step/preparation section const hasIngredients = /ingredi[e|ë]nti/i.test(text); const hasPreparation = /preparazi[o|ó]ne|procedimento|istruzioni|passaggi|how to|steps|zubereitung/i.test(text); const hasStepNumbers = /^\d+[\.\)]/m.test(text); return hasIngredients && (hasPreparation || hasStepNumbers); } async function chatTransferToRecipes(btn, replyText) { btn.disabled = true; btn.textContent = '⏳ ' + (t('chat.transferring') || 'Trasferimento in corso...'); const resetBtn = () => { btn.disabled = false; btn.textContent = '📥 ' + (t('chat.transfer_to_recipes') || 'Trasferisci a Ricette'); }; try { const settings = getSettings(); const result = await api('chat_to_recipe', {}, 'POST', { text: replyText, lang: settings.lang || 'it' }); if (!result || !result.success || !result.recipe) { resetBtn(); showToast('⚠️ ' + (result?.error || t('error.generic') || t('error.generic')), 'error'); return; } const recipe = result.recipe; // renderRecipe expects `persons`; Gemini might return `servings` if (!recipe.persons && recipe.servings) recipe.persons = recipe.servings; if (!recipe.persons) recipe.persons = 2; await saveRecipeToArchive(recipe); _cachedRecipe = { meal: recipe.meal || '', recipe }; await renderRecipe(recipe); // Transform the transfer button into "Apri la ricetta" btn.disabled = false; btn.textContent = '📖 ' + (t('chat.open_recipe') || 'Apri la ricetta'); btn.onclick = () => { 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 = ''; }; showToast('✅ ' + (t('chat.transferred') || 'Aggiunta alle Ricette!'), 'success'); } catch (err) { console.error('[chatTransferToRecipes]', err); resetBtn(); showToast('⚠️ ' + (err.message || t('error.connection')), 'error'); } } async function openIngredientDetail(productId, location) { try { const res = await api('inventory_list'); const items = res.inventory || res; // Find by product_id + location; fallback to any row with that product_id let item = items.find(i => i.product_id === productId && i.location === location); if (!item) item = items.find(i => i.product_id === productId); if (!item) { showToast(t('error.not_found'), 'error'); return; } currentInventory = items; editInventoryItem(item.id); } catch(e) { showToast(t('error.connection'), 'error'); } } async function generateRecipeForIngredient(ingredientName) { if (!_requireGemini()) return; document.getElementById('recipe-overlay').style.display = 'flex'; 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'); if (loadingMsg) loadingMsg.textContent = '👨‍🍳 ' + (t('recipes.loading_msg') || 'Sto preparando la ricetta...'); try { const result = await api('recipe_from_ingredient', {}, 'POST', { ingredient: ingredientName, lang: _currentLang }); if (!result || !result.success || !result.recipe) { document.getElementById('recipe-overlay').style.display = 'none'; showToast('⚠️ ' + (result?.error || t('error.generic') || t('error.generic')), 'error'); return; } const recipe = result.recipe; if (!recipe.persons && recipe.servings) recipe.persons = recipe.servings; if (!recipe.persons) recipe.persons = 2; await saveRecipeToArchive(recipe); _cachedRecipe = { meal: recipe.meal || '', recipe }; await renderRecipe(recipe); document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-result').style.display = ''; } catch (err) { console.error('[generateRecipeForIngredient]', err); document.getElementById('recipe-overlay').style.display = 'none'; showToast('⚠️ ' + t('error.connection'), 'error'); } } 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', '
    ', 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 || '', lang: _currentLang }); // Remove typing indicator typingEl.remove(); if (result.success) { chatHistory.push({ role: 'gemini', text: result.reply }); appendChatBubble('gemini', formatChatReply(result.reply)); // If reply looks like a recipe, add transfer button as SEPARATE element below bubble if (_looksLikeRecipe(result.reply)) { const replyText = result.reply; const container = document.getElementById('chat-messages'); const transferBtn = document.createElement('button'); transferBtn.className = 'btn-chat-use-recipe'; transferBtn.textContent = '📥 ' + (t('chat.transfer_to_recipes') || 'Trasferisci a Ricette'); transferBtn.onclick = () => chatTransferToRecipes(transferBtn, replyText); container.appendChild(transferBtn); scrollChatBottom(); } } else { const errMsg = result.error === 'no_api_key' ? t('error.no_api_key') : (result.error || t('error.generic')); 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, '$1'); // Italic *text* html = html.replace(/\*(.+?)\*/g, '$1'); // Lists html = html.replace(/^- (.+)$/gm, '
  • $1
  • '); html = html.replace(/(
  • .*<\/li>)/s, ''); // Numbered lists html = html.replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • '); // Line breaks html = html.replace(/\n/g, '
    '); // Clean up consecutive ul tags html = html.replace(/<\/ul>\s*
    \s*