@@ -8835,14 +9168,16 @@ function showAddForm() {
showPage('add');
updateScaleReadButtons();
- // After rendering, fetch history-based expiry prediction
- if (currentProduct && currentProduct.id) {
- _fetchExpiryHistoryAndUpdate(currentProduct.id);
- }
- // If Gemini is available and product was just created (no history), ask for AI hint
- if (_geminiAvailable && currentProduct && !currentProduct._aiHintFetched) {
- _applyAIProductHint();
- }
+ // 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() {
@@ -8884,22 +9219,31 @@ function recalculateAddExpiry() {
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();
- if (data.avg_days && data.avg_days > 0 && data.count >= 1) {
+ 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;
- window._historyExpiryCount = data.count;
- // Update the displayed date and label
+ if (_aiProductHintController) {
+ _aiProductHintController.abort();
+ _aiProductHintController = null;
+ }
+ document.getElementById('ai-hint-loading')?.remove();
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 = `
`;
const expiryInput = document.getElementById('add-expiry');
const estimateEl = document.querySelector('.expiry-estimate-label');
const dateEl = document.querySelector('.expiry-estimate-date');
@@ -8907,16 +9251,19 @@ async function _fetchExpiryHistoryAndUpdate(productId) {
if (estimateEl) estimateEl.innerHTML = `${t('add.estimated_expiry')}
`;
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();
@@ -9214,25 +9561,91 @@ function selectLocation(btn, 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();
- showLoading(true);
-
- try {
- const selectedUnit = document.getElementById('add-unit').value;
- const productUnit = currentProduct.unit || 'pz';
-
- // Validate conf fields
- if (selectedUnit === 'conf') {
- const confSize = parseFloat(document.getElementById('add-conf-size')?.value);
- if (!confSize || confSize <= 0) {
- showLoading(false);
- showToast(t('product.conf_size_required'), 'error');
- document.getElementById('add-conf-size')?.focus();
- return;
- }
+
+ const selectedUnit = document.getElementById('add-unit').value;
+ 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;
+ 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 result = await api('inventory_add', {}, 'POST', {
product_id: currentProduct.id,
quantity: parseFloat(document.getElementById('add-quantity').value) || 1,
@@ -9246,6 +9659,16 @@ async function submitAdd(e) {
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) {
@@ -9279,7 +9702,7 @@ async function submitAdd(e) {
}).catch(() => {});
}
}
- if (!spesaModeAfterAdd()) showPage('dashboard');
+ if (!(await spesaModeAfterAdd())) showPage('dashboard');
// Submit extra batches (different expiry dates) in the background, silently
if ((window._addExtraBatches || []).length > 0) {
@@ -11193,7 +11616,12 @@ async function confirmShoppingItemFound() {
/** Build a Bring specification string that encodes urgency + optional brand. */
function _urgencyToSpec(urgency, brand) {
- const urgencyLabels = { critical: t('shopping.urgency_spec_critical'), high: t('shopping.urgency_spec_high'), medium: '', low: '' };
+ const 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;
@@ -11233,7 +11661,7 @@ function _unmarkAutoAddedBring(names) {
// ===== 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 = 4 * 60 * 60 * 1000; // 4 hours
+const _BRING_PURCHASED_TTL = 72 * 60 * 60 * 1000; // 72 h — match server blocklist
function _getBringPurchasedBlocklist() {
const map = Object.assign({}, _bringBlocklistCache || {});
@@ -11259,11 +11687,7 @@ function _markBringPurchased(names) {
}
function _isBringPurchased(name, urgency) {
- // Critical items: blocked only 30 min (enough to put groceries away).
- // High: 90 min. Others: full 4 h.
- const ttl = urgency === 'critical' ? 30 * 60 * 1000
- : urgency === 'high' ? 90 * 60 * 1000
- : _BRING_PURCHASED_TTL;
+ const ttl = _BRING_PURCHASED_TTL;
const map = _getBringPurchasedBlocklist();
const now = Date.now();
return Object.keys(map).some(k => {
@@ -11278,22 +11702,10 @@ async function autoAddCriticalItems() {
const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0');
if (Date.now() - lastRun < 5 * 60 * 1000) return;
localStorage.setItem('_autoAddedCriticalTs', String(Date.now()));
- // Auto-add rules:
- // - critical: always
- // - high: always (PHP already applies strict criteria for high urgency)
- // - medium: when running out within 7 days (<1 week) for items used ≥3x/month
const toAdd = smartShoppingItems.filter(i => {
- const imminentWeek = (i.days_left ?? 999) <= 7 && (i.uses_per_month || 0) >= 3;
if (i.on_bring) return false;
- // For imminent items, do not honor local "purchased" blocklist too aggressively.
- // If they are predicted to finish within a week, keep Bring aligned automatically.
- // Always honour the purchased blocklist so that items the user just removed from Bring!
- // (i.e. bought them at the store) are not immediately re-added before they are scanned.
- if (!imminentWeek && _isBringPurchased(i.name, i.urgency)) return false;
- if (i.urgency === 'critical') return true;
- if (i.urgency === 'high') return true;
- if (i.urgency === 'medium' && (i.days_left ?? 999) <= 7 && (i.uses_per_month || 0) >= 3) return true;
- return false;
+ if (_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) }));
@@ -11451,14 +11863,126 @@ async function syncShoppingPriceTotal(forceRefresh = false) {
* Tries to parse quantity/unit from the Bring! specification field.
*/
function _buildPricePayload() {
- // One retail unit per list item — stable weekly total (server uses the same rule).
- return shoppingItems.map((item) => ({
- name: item.name,
- quantity: 1,
- unit: 'conf',
- default_quantity: 0,
- package_unit: '',
- }));
+ return shoppingItems.map((item) => {
+ const smart = _matchBringToSmart(item.name, smartShoppingItems);
+ if (smart?.suggested_qty > 0) {
+ return {
+ name: item.name,
+ quantity: smart.suggested_qty,
+ unit: smart.suggested_unit || smart.unit || 'conf',
+ default_quantity: smart.default_qty || 0,
+ package_unit: smart.package_unit || '',
+ };
+ }
+ if (smart) {
+ const unit = smart.unit || 'conf';
+ const defQty = parseFloat(smart.default_qty) || 0;
+ const pkgUnit = smart.package_unit || '';
+ if (unit === 'conf' && defQty > 0 && pkgUnit) {
+ return {
+ name: item.name,
+ quantity: defQty,
+ unit: pkgUnit.toLowerCase(),
+ 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;
}
/**
@@ -12052,11 +12576,12 @@ function renderSmartItem(item) {
qtyText = t('shopping.out_of_stock');
}
- // Usage frequency badge
+ // Usage frequency badge — uses per month, not raw transaction count
let freqBadge = '';
- if (item.use_count >= 8) freqBadge = `
`;
+ const usesMonth = Math.round(parseFloat(item.uses_per_month) || 0);
+ if (usesMonth >= 4) freqBadge = `
`;
// Suggested purchase quantity badge
let suggestBadge = '';
@@ -12305,11 +12830,8 @@ function _buildSmartSpec(smartMatch) {
const approx = !!smartMatch.suggested_approx;
const tKey = approx ? 'shopping.suggest_buy_approx' : 'shopping.suggest_buy';
qtyPart = t(tKey).replace('{qty} {unit}', qtyFormatted);
- // Fallback if the key uses separate {qty} and {unit} placeholders
if (qtyPart.includes('{qty}')) {
- qtyPart = t(tKey)
- .replace('{qty}', smartMatch.suggested_qty)
- .replace('{unit}', smartMatch.suggested_unit || smartMatch.unit);
+ qtyPart = (approx ? '🛒 Almeno: ' : '🛒 Compra: ') + qtyFormatted;
}
}
const parts = [urgPart, qtyPart].filter(Boolean);
@@ -12324,10 +12846,10 @@ async function autoSyncUrgencySpecs() {
if (!smartMatch) continue;
const targetSpec = _buildSmartSpec(smartMatch);
const currentSpec = (item.specification || '').trim();
- // Normalise for comparison: ignore case and leading/trailing whitespace
if (targetSpec.toLowerCase() === currentSpec.toLowerCase()) continue;
- toUpdate.push({ name: item.name, specification: targetSpec, update_spec: true });
- // Optimistically update local item so re-render doesn't flicker
+ // 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;
@@ -12424,6 +12946,9 @@ async function loadShoppingList() {
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)) {
@@ -12463,7 +12988,7 @@ 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']) {
+ for (const prefix of ['⚡ urgente', '🟠 presto', '🟡 a breve', '🔵 previsione']) {
if (lower.startsWith(prefix)) {
return spec.slice(prefix.length).replace(/^\s*[·\-]\s*/, '').trim();
}
@@ -12480,12 +13005,10 @@ async function renderShoppingItems() {
const container = document.getElementById('shopping-items');
const countEl = document.getElementById('shopping-count');
- countEl.textContent = shoppingItems.length;
- // Update tab count too
- const tabCount = document.getElementById('tab-count-acquisto');
- if (tabCount) tabCount.textContent = shoppingItems.length;
-
if (shoppingItems.length === 0) {
+ countEl.textContent = 0;
+ const tabCountEmpty = document.getElementById('tab-count-acquisto');
+ if (tabCountEmpty) tabCountEmpty.textContent = 0;
container.innerHTML = `
`;
return;
}
@@ -12501,12 +13024,10 @@ async function renderShoppingItems() {
low: { icon: '🟢', label: t('shopping.urgency_low_short'), cls: 'badge-low' },
};
- // Map each item to its section + urgency (strict first-token matching to avoid false positives)
- // Also derive urgency from Bring specification if smart matching fails
- const enriched = shoppingItems.map((item, idx) => {
+ // 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;
- // Fallback: read urgency from Bring specification (set by our app when adding)
if (!urgency && item.specification) {
const spec = item.specification.toLowerCase();
if (spec.includes('urgente')) urgency = 'critical';
@@ -12515,6 +13036,11 @@ async function renderShoppingItems() {
const sec = getItemSection(item.name);
return { item, idx, smartData, urgency, sec };
});
+ const enriched = _dedupeShoppingByGeneric(enrichedRaw);
+
+ 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();
@@ -12542,44 +13068,47 @@ async function renderShoppingItems() {
html += `
`;
- for (const { item, idx, smartData, urgency } of group.items) {
+ 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);
- const displayName = isGenericGroup ? shoppingName : item.name;
+ && (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 (isGenericGroup) {
+ 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 (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 = `
`;
}
}
- // Urgency badge
+ // Urgency badge (spec urgency markers are stripped in _specDisplayText)
let urgencyBadge = '';
if (urgency && urgencyMap[urgency]) {
const u = urgencyMap[urgency];
urgencyBadge = `
`;
}
- // Frequency badge
+ // Frequency: uses per month (not raw transaction count)
let freqBadge = '';
- if (smartData && smartData.use_count >= 8) freqBadge = `
`;
+ const usesMonth = smartData ? Math.round(parseFloat(smartData.uses_per_month) || 0) : 0;
+ if (usesMonth >= 4) freqBadge = `
`
@@ -12624,23 +13153,23 @@ async function renderShoppingItems() {
// ── 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 } of enriched) {
- const firstTok = (_nameTokens(item.name)[0] || '').toLowerCase();
- if (!firstTok) continue;
- const matches = invItems.filter(i => {
- const iFirst = (_nameTokens(i.name || '')[0] || '').toLowerCase();
- return iFirst === firstTok && parseFloat(i.quantity) > 0;
- });
+ for (const { item, idx, smartData, urgency } of enriched) {
+ const matches = _shoppingFamilyInventoryRows(item, smartData, invItems);
if (matches.length === 0) continue;
- // Group by unit and sum
- const byUnit = {};
+ // 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 u = m.unit || 'pz';
- byUnit[u] = (byUnit[u] || 0) + parseFloat(m.quantity);
+ const label = _formatInvQtyDisplay(m.quantity, m.unit, m.default_quantity, m.package_unit);
+ if (!label) continue;
+ byKey[label] = (byKey[label] || 0) + 1;
}
- const hintText = Object.entries(byUnit)
- .map(([u, q]) => `${Math.round(q * 10) / 10} ${u}`)
- .join(', ');
+ 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');
@@ -16527,6 +17056,7 @@ const _NETWORK_FAIL_THRESHOLD = 3;
const _OFFLINE_MODE_DELAY_MS = 8000; // auto-enter offline mode after 8 s of overlay
const _OFFLINE_CACHE_KEY = '_evershelf_inv_cache';
const _OFFLINE_SETTINGS_KEY = '_evershelf_settings_cache';
+const _OFFLINE_PRODUCTS_KEY = '_evershelf_products_cache';
const _OFFLINE_QUEUE_KEY = '_evershelf_op_queue';
// ─── Local cache helpers ────────────────────────────────────────────────────
@@ -16542,6 +17072,41 @@ function _offlineCacheGetSettings() {
function _offlineCacheSetSettings(settings) {
try { localStorage.setItem(_OFFLINE_SETTINGS_KEY, JSON.stringify(settings)); } catch(e) {}
}
+function _offlineProductsGet() {
+ try { return JSON.parse(localStorage.getItem(_OFFLINE_PRODUCTS_KEY)) || null; } catch { return null; }
+}
+function _offlineProductsSet(products) {
+ try { localStorage.setItem(_OFFLINE_PRODUCTS_KEY, JSON.stringify(products)); } catch(e) {}
+}
+function _offlineProductFromInventoryItem(item) {
+ if (!item || !item.barcode) return null;
+ return {
+ id: item.product_id,
+ barcode: item.barcode,
+ name: item.name || '',
+ brand: item.brand || '',
+ category: item.category || '',
+ image_url: item.image_url || '',
+ unit: item.unit || 'pz',
+ default_quantity: item.default_quantity ?? 1,
+ package_unit: item.package_unit || '',
+ notes: item.notes || '',
+ };
+}
+function _offlineSearchBarcode(barcode) {
+ const code = String(barcode || '').trim();
+ if (!code) return { found: false, _offline: true };
+ const products = _offlineProductsGet() || [];
+ const fromProducts = products.find(p => String(p.barcode || '') === code);
+ if (fromProducts) return { found: true, product: fromProducts, _offline: true };
+ const inv = _offlineCacheGet() || [];
+ const fromInv = inv.find(i => String(i.barcode || '') === code);
+ if (fromInv) {
+ const product = _offlineProductFromInventoryItem(fromInv);
+ if (product) return { found: true, product, _offline: true };
+ }
+ return { found: false, _offline: true };
+}
function _offlineQueueGet() {
try { return JSON.parse(localStorage.getItem(_OFFLINE_QUEUE_KEY)) || []; } catch { return []; }
}
@@ -16604,10 +17169,17 @@ function _handleOfflineApi(action, params, body) {
if (cached) return { ...cached, _offline: true };
return { success: false, _offline: true };
}
- if (action === 'get_settings') {
- const cached = _offlineCacheGetSettings();
- if (cached) return { ...cached, _offline: true };
- return { success: false, _offline: true };
+ if (action === 'search_barcode') {
+ return _offlineSearchBarcode(params && params.barcode);
+ }
+ if (action === 'products_search') {
+ const q = String((params && params.q) || '').trim().toLowerCase();
+ if (!q || q.length < 2) return { success: true, products: [], _offline: true };
+ const products = (_offlineProductsGet() || []).filter(p => {
+ const hay = `${p.name || ''} ${p.brand || ''} ${p.barcode || ''}`.toLowerCase();
+ return hay.includes(q);
+ }).slice(0, 20);
+ return { success: true, products, _offline: true };
}
// Safe empty responses for read-only endpoints that can't be served from cache
const EMPTY_READS = {
@@ -17521,20 +18093,155 @@ function updateSpesaBanner() {
banner.style.display = _spesaMode ? 'flex' : 'none';
const statEl = banner.querySelector('.spesa-stat');
if (statEl) statEl.textContent = _spesaBannerStat();
+ _applySpesaScanUI();
+}
+
+/** Spesa mode: keep tabs + normal camera size — only hide manual barcode input. */
+function _applySpesaScanUI() {
+ const page = document.getElementById('page-scan');
+ if (page) page.classList.toggle('spesa-scan-layout', _spesaMode);
+
+ const spesaBtn = document.getElementById('scan-spesa-btn');
+ if (spesaBtn) spesaBtn.style.display = _spesaMode ? 'none' : '';
+
+ const barcodeContent = document.getElementById('scan-tabcontent-barcode');
+ let hint = document.getElementById('spesa-scan-barcode-hint');
+
+ if (_spesaMode) {
+ if (barcodeContent && !hint) {
+ hint = document.createElement('p');
+ hint.id = 'spesa-scan-barcode-hint';
+ hint.className = 'spesa-scan-barcode-hint';
+ hint.setAttribute('data-i18n', 'scan.spesa_camera_hint');
+ hint.textContent = t('scan.spesa_camera_hint');
+ barcodeContent.appendChild(hint);
+ }
+ if (hint) hint.style.display = '';
+ const active = document.activeElement;
+ if (active && active.id === 'manual-barcode-input') active.blur();
+ if (_currentPageId === 'scan') switchScanTab('barcode');
+ return;
+ }
+
+ if (hint) hint.style.display = 'none';
+ if (_currentPageId === 'scan') switchScanTab('barcode');
+ _updateScanAiButton();
}
// Called after successful add — returns true if spesa mode handled navigation
-function spesaModeAfterAdd() {
+async function spesaModeAfterAdd() {
if (!_spesaMode) return false;
- // Track this product in the session
if (currentProduct) {
- _spesaSession.push({ name: currentProduct.name, category: currentProduct.category || '' });
+ _spesaSession.push({
+ name: currentProduct.name,
+ category: currentProduct.category || '',
+ product_id: currentProduct.id,
+ });
updateSpesaBanner();
+ await _spesaRemovePurchasedFromList(currentProduct);
+ const addLoc = document.getElementById('add-location')?.value || 'dispensa';
+ _showFamilySiblingSuggest(currentProduct.id, addLoc);
}
showPage('scan');
return true;
}
+/** Remove matching shopping-list / Bring entry after a spesa-mode purchase. */
+async function _spesaRemovePurchasedFromList(product) {
+ const namesToMark = [product.name];
+ if (product.shopping_name) namesToMark.push(product.shopping_name);
+ if (shoppingListUUID && shoppingItems.length > 0) {
+ const generic = product.shopping_name || product.name;
+ const match = _findSimilarItem(generic, shoppingItems) || _findSimilarItem(product.name, shoppingItems);
+ if (match) {
+ try {
+ const r = await api('shopping_remove', {}, 'POST', {
+ name: match.name,
+ rawName: match.rawName || '',
+ listUUID: shoppingListUUID,
+ });
+ if (r?.success) {
+ shoppingItems = shoppingItems.filter(i => i !== match);
+ namesToMark.push(match.name);
+ }
+ } catch (_) { /* best effort */ }
+ }
+ }
+ _markBringPurchased(namesToMark);
+}
+
+let _familySiblingDismissTimer = null;
+
+function _dismissFamilySiblingPrompt() {
+ clearTimeout(_familySiblingDismissTimer);
+ _familySiblingDismissTimer = null;
+ const el = document.getElementById('_family-sibling-prompt');
+ if (el) el.remove();
+}
+
+function _formatFamilySiblingDate(dtStr) {
+ if (!dtStr) return '';
+ const d = new Date(String(dtStr).replace(' ', 'T'));
+ if (isNaN(d.getTime())) return '';
+ const loc = _currentLang === 'de' ? 'de-DE' : _currentLang === 'en' ? 'en-GB' : 'it-IT';
+ return d.toLocaleDateString(loc, { day: '2-digit', month: 'long', year: 'numeric' });
+}
+
+/** Optional hint: same-family product in the same location (non-blocking). */
+function _showFamilySiblingSuggest(productId, location) {
+ _dismissFamilySiblingPrompt();
+ const loc = location || 'dispensa';
+ api('family_sibling_suggest', {}, 'POST', { product_id: productId, location: loc }).then(data => {
+ if (!data?.success || !data.sibling) return;
+ const s = data.sibling;
+ const locKey = s.location || loc;
+ const locInfo = LOCATIONS[locKey] || LOCATIONS.altro;
+ const qtyStr = `${s.stock_qty} ${s.unit}`;
+ const purchaseRaw = s.last_purchase_at || s.added_at;
+ const purchaseDate = _formatFamilySiblingDate(purchaseRaw);
+ const productLine = s.brand ? `${s.name} (${s.brand})` : s.name;
+ const catIcon = CATEGORY_ICONS[mapToLocalCategory(s.category, s.name)] || '📦';
+ const metaParts = [
+ `${locInfo.icon} ${locInfo.label}`,
+ qtyStr,
+ purchaseDate ? purchaseDate : '',
+ ].filter(Boolean);
+ const thumbHtml = s.image_url
+ ? `
`;
+
+ const bar = document.createElement('div');
+ bar.id = '_family-sibling-prompt';
+ bar.className = 'family-sibling-prompt';
+ bar.innerHTML = `
+
+
${thumbHtml}
+
+
${escapeHtml(t('shopping.family_sibling_title', { location: locInfo.label }))}
+
${escapeHtml(productLine)}
+
${escapeHtml(metaParts.join(' · '))}
+
${escapeHtml(t('shopping.family_sibling_question'))}
+
+
+ `;
+ document.body.appendChild(bar);
+
+ bar.querySelector('#_fam-sib-yes').addEventListener('click', _dismissFamilySiblingPrompt);
+ bar.querySelector('#_fam-sib-no').addEventListener('click', () => {
+ _dismissFamilySiblingPrompt();
+ if (s.inventory_id) editInventoryItem(s.inventory_id);
+ else if (s.product_id) api('product_get', { id: s.product_id }).then(p => {
+ if (p?.product) { currentProduct = p.product; showPage('add'); }
+ }).catch(() => {});
+ });
+ _familySiblingDismissTimer = setTimeout(_dismissFamilySiblingPrompt, 90000);
+ }).catch(() => {});
+}
+
function _spesaBannerStat() {
const n = _spesaSession.length;
if (n === 0) return t('shopping.session_empty');
@@ -18192,12 +18899,14 @@ async function _runStartupCheck() {
try {
setProgress(100, tl('syncing_local', 'Sincronizzazione dati locali...'), 'ok');
const authH = typeof apiAuthHeaders === 'function' ? apiAuthHeaders() : {};
- const [invData, settingsData] = await Promise.all([
+ const [invData, settingsData, productsData] = await Promise.all([
fetch('api/index.php?action=inventory_list', { headers: authH }).then(r => r.json()).catch(() => null),
fetch('api/index.php?action=get_settings', { headers: authH }).then(r => r.json()).catch(() => null),
+ fetch('api/index.php?action=products_list', { headers: authH }).then(r => r.json()).catch(() => null),
]);
if (invData && Array.isArray(invData.inventory)) _offlineCacheSet(invData.inventory);
if (settingsData && settingsData.success !== false) _offlineCacheSetSettings(settingsData);
+ if (productsData && Array.isArray(productsData.products)) _offlineProductsSet(productsData.products);
setProgress(100, tl('sync_done', 'Dati locali aggiornati'), 'ok');
await new Promise(r => setTimeout(r, 400));
} catch(e) {
@@ -18439,6 +19148,10 @@ async function _backgroundBringSync() {
shoppingListUUID = listUUID;
shoppingItems = bringItems;
+ if (bringData.recently?.length) {
+ _markBringPurchased(bringData.recently.map(i => i.name).filter(Boolean));
+ }
+
const toAdd = []; // new items not yet on Bring
const toUpdate = []; // items on Bring that need spec updated
const toRemove = []; // items on Bring that are no longer urgent (auto-added, now resolved)
@@ -18456,12 +19169,11 @@ async function _backgroundBringSync() {
});
if (!bringMatch) {
- // Not on Bring — add if high/critical and not blocklisted
- if ((si.urgency === 'critical' || si.urgency === 'high') && !_isBringPurchased(si.name, si.urgency)) {
+ if (['critical', 'high', 'medium', 'low'].includes(si.urgency) && !_isBringPurchased(si.name, si.urgency)) {
toAdd.push({ name: si.name, specification: expectedSpec });
autoAdded.add(si.name.toLowerCase());
}
- } else {
+ } else if (!_isBringPurchased(si.name, si.urgency)) {
// Already on Bring — sync urgency spec unconditionally
const currentSpec = (bringMatch.specification || '').toLowerCase();
const hasUrgencyMarker = currentSpec.includes('urgente') || currentSpec.includes('presto');
diff --git a/assets/vendor/tesseract/lang/eng.traineddata.gz b/assets/vendor/tesseract/lang/eng.traineddata.gz
new file mode 100644
index 0000000..730a6a5
Binary files /dev/null and b/assets/vendor/tesseract/lang/eng.traineddata.gz differ
diff --git a/assets/vendor/tesseract/tesseract-core-simd.wasm.js b/assets/vendor/tesseract/tesseract-core-simd.wasm.js
new file mode 100644
index 0000000..1bf4e66
--- /dev/null
+++ b/assets/vendor/tesseract/tesseract-core-simd.wasm.js
@@ -0,0 +1,281 @@
+
+var TesseractCore = (() => {
+ var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;
+ if (typeof __filename !== 'undefined') _scriptDir = _scriptDir || __filename;
+ return (
+function(TesseractCore = {}) {
+
+var b;b||(b=typeof TesseractCore !== 'undefined' ? TesseractCore : {});var aa,ba;b.ready=new Promise((a,c)=>{aa=a;ba=c});var ca=Object.assign({},b),da="./this.program",ea=(a,c)=>{throw c;},fa="object"==typeof window,ha="function"==typeof importScripts,ia="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,f="",ja,ka,la;
+if(ia){var fs=require("fs"),ma=require("path");f=ha?ma.dirname(f)+"/":__dirname+"/";ja=(a,c)=>{var d=na(a);if(d)return c?d:d.toString();a=a.startsWith("file://")?new URL(a):ma.normalize(a);return fs.readFileSync(a,c?void 0:"utf8")};la=a=>{a=ja(a,!0);a.buffer||(a=new Uint8Array(a));return a};ka=(a,c,d,e=!0)=>{var g=na(a);g&&c(g);a=a.startsWith("file://")?new URL(a):ma.normalize(a);fs.readFile(a,e?void 0:"utf8",(h,k)=>{h?d(h):c(e?k.buffer:k)})};!b.thisProgram&&1