feat: bring urgency sync, background auto-sync, recipe mealplan chip, screensaver fix
This commit is contained in:
@@ -2970,6 +2970,155 @@ body {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ===== WEEKLY MEAL PLAN ===== */
|
||||
.mplan-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.mplan-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px 2px;
|
||||
padding-left: 44px;
|
||||
}
|
||||
.mplan-col-header {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.mplan-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
}
|
||||
.mplan-row-today {
|
||||
background: rgba(45,80,22,0.08);
|
||||
outline: 2px solid var(--primary-light);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.mplan-day-name {
|
||||
width: 36px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.mplan-badge {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 7px 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.mplan-badge:active { transform: scale(0.93); }
|
||||
.mplan-badge-pranzo { background: var(--primary-light); }
|
||||
.mplan-badge-cena { background: #1e3a6e; }
|
||||
|
||||
/* Meal plan picker popup */
|
||||
.mplan-picker {
|
||||
position: fixed;
|
||||
z-index: 600;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%);
|
||||
width: min(320px, 92vw);
|
||||
}
|
||||
.mplan-pick-btn {
|
||||
background: var(--bg);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 7px 12px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.mplan-pick-btn.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.mplan-pick-btn:active { opacity: 0.7; }
|
||||
|
||||
/* Legend in settings */
|
||||
.mplan-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Recipe dialog banner (top strip) */
|
||||
.recipe-mealplan-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 11px 20px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
margin: -20px -20px 16px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
/* Recipe dialog hint */
|
||||
.recipe-mealplan-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(45,80,22,0.07);
|
||||
border: 1px solid rgba(45,80,22,0.18);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.mplan-hint-badge {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mplan-hint-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.80rem;
|
||||
}
|
||||
|
||||
/* ===== COOKING MODE ===== */
|
||||
.cooking-overlay {
|
||||
position: fixed;
|
||||
@@ -3191,6 +3340,29 @@ body {
|
||||
to { opacity: 0.5; transform: scale(1.06); }
|
||||
}
|
||||
|
||||
/* ===== COOKING SCREEN FLASH ===== */
|
||||
.cooking-flash-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
background: transparent;
|
||||
}
|
||||
.cooking-flash-overlay.flash-warning {
|
||||
animation: cookFlashOrange 1.1s ease-in-out infinite;
|
||||
}
|
||||
.cooking-flash-overlay.flash-done {
|
||||
animation: cookFlashRed 0.65s ease-in-out infinite;
|
||||
}
|
||||
@keyframes cookFlashOrange {
|
||||
0%, 100% { background: transparent; }
|
||||
50% { background: rgba(249, 115, 22, 0.28); }
|
||||
}
|
||||
@keyframes cookFlashRed {
|
||||
0%, 100% { background: transparent; }
|
||||
50% { background: rgba(220, 38, 38, 0.42); }
|
||||
}
|
||||
|
||||
.ctimer-btns {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -3664,6 +3836,23 @@ body {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Meal-plan chip: visually highlighted to stand out as the planned food type */
|
||||
.recipe-opt-mealplan-chip {
|
||||
grid-column: 1 / -1;
|
||||
background: rgba(100, 60, 20, 0.07);
|
||||
border-color: #b07830;
|
||||
font-weight: 600;
|
||||
}
|
||||
.recipe-opt-mealplan-chip:has(input:checked) {
|
||||
background: rgba(120, 70, 10, 0.13);
|
||||
border-color: #c08020;
|
||||
}
|
||||
.recipe-opt-mealplan-chip:has(input:not(:checked)) {
|
||||
opacity: 0.6;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #c08020;
|
||||
}
|
||||
|
||||
.recipe-option-chip input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -4628,6 +4817,24 @@ body {
|
||||
.screensaver-fact.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.screensaver-mealplan {
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
margin: -8px 0 4px;
|
||||
}
|
||||
.screensaver-mealplan-badge {
|
||||
display: inline-block;
|
||||
background: rgba(255,255,255,0.10);
|
||||
color: rgba(255,255,255,0.70);
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
border-radius: 28px;
|
||||
padding: 8px 24px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.4px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.screensaver-shortcuts {
|
||||
position: absolute;
|
||||
bottom: max(32px, env(safe-area-inset-bottom, 32px));
|
||||
|
||||
+552
-19
@@ -633,6 +633,21 @@ async function loadSettingsUI() {
|
||||
loadCameraDevices();
|
||||
renderAppliances(s.appliances || []);
|
||||
loadSpesaSettings();
|
||||
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 = MEAL_PLAN_TYPES.map(t =>
|
||||
`<span class="mplan-badge" style="opacity:0.85">${t.icon} ${t.label}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Load server-side settings if not already set locally
|
||||
try {
|
||||
@@ -730,6 +745,9 @@ async function saveSettings() {
|
||||
s.dietary = document.getElementById('setting-dietary').value.trim();
|
||||
// Camera
|
||||
s.camera_facing = document.getElementById('setting-camera-facing').value;
|
||||
// Meal plan enabled toggle
|
||||
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
||||
if (mpEnabledEl) s.meal_plan_enabled = mpEnabledEl.checked;
|
||||
// Save spesa AI prompt if the field exists
|
||||
const spesaPromptEl = document.getElementById('setting-spesa-ai-prompt');
|
||||
if (spesaPromptEl) s.spesa_ai_prompt = spesaPromptEl.value.trim();
|
||||
@@ -3661,6 +3679,7 @@ function _nameTokens(name) {
|
||||
* 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);
|
||||
@@ -3671,6 +3690,40 @@ function _findSimilarItem(name, list) {
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict matching: find the smart item that corresponds to a Bring item by name.
|
||||
* Rules (in order):
|
||||
* 1. Exact case-insensitive match.
|
||||
* 2. First significant token of both names must be identical
|
||||
* ("Latte" → "Latte Parzialmente Scremato" ✓; "Frutta" ≠ "Muesli Frutta Secca" ✗).
|
||||
* 3. For multi-token Bring names: all Bring tokens appear in the smart item tokens.
|
||||
* This avoids false positives when a generic word ("frutta", "noci") appears as a
|
||||
* secondary word inside an unrelated long product name.
|
||||
*/
|
||||
function _matchBringToSmart(bringName, smartItems) {
|
||||
const bLower = bringName.toLowerCase();
|
||||
const exact = smartItems.find(sd => sd.name.toLowerCase() === bLower);
|
||||
if (exact) return exact;
|
||||
const bTokens = _nameTokens(bringName);
|
||||
if (bTokens.length === 0) return null;
|
||||
const bFirst = bTokens[0];
|
||||
// Rule 2: first token match
|
||||
const firstMatch = smartItems.find(sd => {
|
||||
const sdTokens = _nameTokens(sd.name);
|
||||
return sdTokens.length > 0 && sdTokens[0] === bFirst;
|
||||
});
|
||||
if (firstMatch) return firstMatch;
|
||||
// Rule 3: multi-token full subset
|
||||
if (bTokens.length >= 2) {
|
||||
const allMatch = smartItems.find(sd => {
|
||||
const sdTokens = _nameTokens(sd.name);
|
||||
return bTokens.every(t => sdTokens.includes(t));
|
||||
});
|
||||
if (allMatch) return allMatch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function showLowStockBringPrompt(result, afterCallback) {
|
||||
const name = result.product_name || currentProduct?.name || '';
|
||||
const unit = result.product_unit || currentProduct?.unit || 'pz';
|
||||
@@ -4288,6 +4341,19 @@ function toggleShoppingTag(itemIdx, tag) {
|
||||
if (existing.length) tags[key] = existing;
|
||||
else delete tags[key];
|
||||
localStorage.setItem('shopping_tags', JSON.stringify(tags));
|
||||
|
||||
// Sync urgente/presto tag to Bring specification so it's visible in the Bring app
|
||||
if (tag === 'urgente' && shoppingListUUID) {
|
||||
const isNowUrgent = existing.includes('urgente');
|
||||
const newSpec = isNowUrgent ? '⚡ Urgente' : '';
|
||||
api('bring_add', {}, 'POST', {
|
||||
items: [{ name: item.name, specification: newSpec, update_spec: true }],
|
||||
listUUID: shoppingListUUID,
|
||||
}).catch(() => {});
|
||||
// Update local item spec for immediate re-render
|
||||
item.specification = newSpec;
|
||||
}
|
||||
|
||||
renderShoppingItems();
|
||||
} catch (e) { console.error('toggleShoppingTag', e); }
|
||||
}
|
||||
@@ -4320,12 +4386,24 @@ async function confirmShoppingItemFound() {
|
||||
}
|
||||
|
||||
// ===== AUTO-ADD CRITICAL ITEMS TO BRING! =====
|
||||
|
||||
/** Build a Bring specification string that encodes urgency + optional brand. */
|
||||
function _urgencyToSpec(urgency, brand) {
|
||||
const urgencyLabels = { critical: '⚡ Urgente', high: '🟠 Presto', medium: '', low: '' };
|
||||
const urgLabel = urgencyLabels[urgency] || '';
|
||||
if (urgLabel && brand) return `${urgLabel} · ${brand}`;
|
||||
if (urgLabel) return urgLabel;
|
||||
return brand || '';
|
||||
}
|
||||
|
||||
async function autoAddCriticalItems() {
|
||||
if (sessionStorage.getItem('_autoAddedCritical')) return;
|
||||
sessionStorage.setItem('_autoAddedCritical', '1');
|
||||
// Time-based guard: run at most once every 10 minutes (not session-based, so new critical items get added promptly)
|
||||
const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0');
|
||||
if (Date.now() - lastRun < 10 * 60 * 1000) return;
|
||||
localStorage.setItem('_autoAddedCriticalTs', String(Date.now()));
|
||||
const critical = smartShoppingItems.filter(i => i.urgency === 'critical' && !i.on_bring);
|
||||
if (critical.length === 0) return;
|
||||
const itemsToAdd = critical.map(i => ({ name: i.name, specification: i.brand || '' }));
|
||||
const itemsToAdd = critical.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) }));
|
||||
try {
|
||||
const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID });
|
||||
if (result.success && result.added > 0) {
|
||||
@@ -4353,7 +4431,7 @@ async function cleanupObsoleteBringItems() {
|
||||
// Load all products from our DB to cross-reference
|
||||
let allProducts = [];
|
||||
try {
|
||||
const res = await api('products');
|
||||
const res = await api('products_list');
|
||||
allProducts = res.products || res.data || [];
|
||||
} catch (e) { return; }
|
||||
if (!allProducts.length) return;
|
||||
@@ -4529,6 +4607,28 @@ function _updateSmartUrgencyBadge() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
si.on_bring = !!(
|
||||
shoppingItems.find(bi => bi.name.toLowerCase() === siLower) ||
|
||||
(siFirst && shoppingItems.find(bi => {
|
||||
const biFirst = _nameTokens(bi.name)[0];
|
||||
return biFirst === siFirst;
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderSmartLastUpdate() {
|
||||
const el = document.getElementById('smart-last-update');
|
||||
if (!el || !_smartShoppingLastFetch) return;
|
||||
@@ -4704,7 +4804,7 @@ async function addSmartToBring() {
|
||||
if (item) {
|
||||
itemsToAdd.push({
|
||||
name: item.name,
|
||||
specification: item.brand || '',
|
||||
specification: _urgencyToSpec(item.urgency, item.brand),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4759,6 +4859,67 @@ async function loadShoppingCount() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync local 'urgente' tag from Bring specification.
|
||||
* If a Bring item's specification contains 'urgente', ensure the local tag is set.
|
||||
* If a Bring item's specification is empty/cleared, remove the local urgente tag
|
||||
* UNLESS smart shopping considers it critical (to avoid losing urgency on stale specs).
|
||||
*/
|
||||
function _syncTagsFromBringSpec() {
|
||||
try {
|
||||
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
|
||||
let changed = false;
|
||||
for (const item of shoppingItems) {
|
||||
const key = item.name.toLowerCase();
|
||||
const spec = (item.specification || '').toLowerCase();
|
||||
const existing = tags[key] || [];
|
||||
const hasUrgente = existing.includes('urgente');
|
||||
const smartMatch = _matchBringToSmart(item.name, smartShoppingItems);
|
||||
const smartIsCritical = smartMatch && (smartMatch.urgency === 'critical' || smartMatch.urgency === 'high');
|
||||
if ((spec.includes('urgente') || spec.includes('presto') || smartIsCritical) && !hasUrgente) {
|
||||
existing.push('urgente');
|
||||
tags[key] = existing;
|
||||
changed = true;
|
||||
} else if (!spec.includes('urgente') && !spec.includes('presto') && !smartIsCritical && hasUrgente) {
|
||||
existing.splice(existing.indexOf('urgente'), 1);
|
||||
if (existing.length) tags[key] = existing;
|
||||
else delete tags[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) localStorage.setItem('shopping_tags', JSON.stringify(tags));
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* After smart shopping loads, push urgency specifications to Bring for all matched items.
|
||||
* This makes urgency visible in the native Bring app via the item specification field.
|
||||
* Only updates if the spec has changed (to avoid unnecessary API calls).
|
||||
*/
|
||||
async function autoSyncUrgencySpecs() {
|
||||
if (!shoppingListUUID || !smartShoppingItems.length) return;
|
||||
const toUpdate = [];
|
||||
for (const item of shoppingItems) {
|
||||
const smartMatch = _matchBringToSmart(item.name, smartShoppingItems);
|
||||
if (!smartMatch) continue;
|
||||
const expectedSpec = _urgencyToSpec(smartMatch.urgency, '');
|
||||
const currentSpec = (item.specification || '').toLowerCase();
|
||||
// Only update if urgency marker changed (don't clobber user-set spec info that isn't urgency)
|
||||
const currentHasUrgencyMarker = currentSpec.includes('urgente') || currentSpec.includes('presto');
|
||||
const needsUpdate = expectedSpec && !currentHasUrgencyMarker;
|
||||
const needsClear = !expectedSpec && currentHasUrgencyMarker;
|
||||
if (needsUpdate || needsClear) {
|
||||
toUpdate.push({ name: item.name, specification: expectedSpec, update_spec: true });
|
||||
// Optimistically update local item so re-render is immediate
|
||||
item.specification = expectedSpec;
|
||||
}
|
||||
}
|
||||
if (toUpdate.length === 0) return;
|
||||
try {
|
||||
await api('bring_add', {}, 'POST', { items: toUpdate, listUUID: shoppingListUUID });
|
||||
} catch (e) { /* ignore - sync is best-effort */ }
|
||||
}
|
||||
|
||||
async function loadShoppingList() {
|
||||
const statusEl = document.getElementById('bring-status');
|
||||
const currentEl = document.getElementById('shopping-current');
|
||||
@@ -4794,19 +4955,23 @@ async function loadShoppingList() {
|
||||
if (pricesChanged) saveShoppingPrices();
|
||||
|
||||
loadShoppingPrices();
|
||||
// Sync urgente local tags from Bring specification (items marked urgent by us or manually)
|
||||
_syncTagsFromBringSpec();
|
||||
renderShoppingItems();
|
||||
currentEl.style.display = 'block';
|
||||
|
||||
// Load smart shopping predictions, then re-render to show badges + auto-add critical
|
||||
loadSmartShopping().then(() => {
|
||||
_syncOnBringFlags(); // sync on_bring against current Bring list before any logic reads it
|
||||
_syncTagsFromBringSpec(); // re-sync tags now that smart data is available
|
||||
autoSyncUrgencySpecs(); // push urgency specs to Bring for matched items
|
||||
renderSmartShopping(); // re-render smart tab with corrected on_bring flags
|
||||
updateShoppingTabCounts(); // update tab badges with corrected counts
|
||||
autoAddCriticalItems();
|
||||
cleanupObsoleteBringItems();
|
||||
renderShoppingItems(); // re-render to apply urgency badges from smart data
|
||||
renderShoppingItems(); // re-render shopping tab with urgency badges
|
||||
});
|
||||
|
||||
// Show tabs once data is ready
|
||||
updateShoppingTabCounts();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Bring! error:', err);
|
||||
statusEl.style.display = 'block';
|
||||
@@ -4814,6 +4979,24 @@ async function loadShoppingList() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the spec text to show in the UI, stripping urgency markers (those are shown as badges). */
|
||||
function _specDisplayText(spec) {
|
||||
if (!spec) return '';
|
||||
// Strip known urgency prefixes set by _urgencyToSpec (case-insensitive, then trim separator)
|
||||
const lower = spec.toLowerCase();
|
||||
for (const prefix of ['⚡ urgente', '🟠 presto']) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
return spec.slice(prefix.length).replace(/^\s*[·\-]\s*/, '').trim();
|
||||
}
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** Return the spec for price search, stripping urgency markers that would confuse the AI. */
|
||||
function _cleanSpecForSearch(spec) {
|
||||
return _specDisplayText(spec);
|
||||
}
|
||||
|
||||
async function renderShoppingItems() {
|
||||
const container = document.getElementById('shopping-items');
|
||||
const countEl = document.getElementById('shopping-count');
|
||||
@@ -4855,10 +5038,17 @@ async function renderShoppingItems() {
|
||||
low: { icon: '🟢', label: 'Ok', cls: 'badge-low' },
|
||||
};
|
||||
|
||||
// Map each item to its section + urgency
|
||||
// Map each item to its section + urgency (strict first-token matching to avoid false positives)
|
||||
// Also derive urgency from Bring specification if smart matching fails
|
||||
const enriched = shoppingItems.map((item, idx) => {
|
||||
const smartData = smartShoppingItems.find(sd => sd.name.toLowerCase() === item.name.toLowerCase());
|
||||
const urgency = smartData?.urgency || null;
|
||||
const smartData = _matchBringToSmart(item.name, smartShoppingItems);
|
||||
let urgency = smartData?.urgency || null;
|
||||
// Fallback: read urgency from Bring specification (set by our app when adding)
|
||||
if (!urgency && item.specification) {
|
||||
const spec = item.specification.toLowerCase();
|
||||
if (spec.includes('urgente')) urgency = 'critical';
|
||||
else if (spec.includes('presto')) urgency = 'high';
|
||||
}
|
||||
const sec = getItemSection(item.name);
|
||||
return { item, idx, smartData, urgency, sec };
|
||||
});
|
||||
@@ -4965,7 +5155,7 @@ async function renderShoppingItems() {
|
||||
<span class="shopping-item-name">${escapeHtml(item.name)}</span>
|
||||
<span class="shopping-item-scan-hint">📷</span>
|
||||
</div>
|
||||
${item.specification ? `<div class="shopping-item-spec">${escapeHtml(item.specification)}</div>` : ''}
|
||||
${_specDisplayText(item.specification) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''}
|
||||
${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''}
|
||||
${detailHtml}
|
||||
</div>
|
||||
@@ -5052,9 +5242,9 @@ async function searchItemPrice(idx, force = false) {
|
||||
renderShoppingItems();
|
||||
|
||||
try {
|
||||
// Send item name as query, spec separately for AI selection
|
||||
// Send item name as query, spec separately for AI selection (strip urgency markers)
|
||||
const searchQ = item.name;
|
||||
const spec = item.specification || '';
|
||||
const spec = _cleanSpecForSearch(item.specification || '');
|
||||
|
||||
const s2 = getSettings();
|
||||
const aiPrompt = s2.spesa_ai_prompt || '';
|
||||
@@ -5064,7 +5254,7 @@ async function searchItemPrice(idx, force = false) {
|
||||
prompt: aiPrompt
|
||||
});
|
||||
if (res.success && res.product) {
|
||||
shoppingPrices[priceKey] = { searched: true, product: res.product, spec: item.specification || '' };
|
||||
shoppingPrices[priceKey] = { searched: true, product: res.product, spec: _cleanSpecForSearch(item.specification || '') };
|
||||
} else {
|
||||
shoppingPrices[priceKey] = { searched: true, product: null };
|
||||
}
|
||||
@@ -5120,11 +5310,11 @@ async function searchAllPrices() {
|
||||
const aiPrompt = s.spesa_ai_prompt || '';
|
||||
const res = await api(`${provider}_search`, {
|
||||
q: item.name,
|
||||
spec: item.specification || '',
|
||||
spec: _cleanSpecForSearch(item.specification || ''),
|
||||
prompt: aiPrompt
|
||||
});
|
||||
if (res.success && res.product) {
|
||||
shoppingPrices[priceKey] = { searched: true, product: res.product, spec: item.specification || '' };
|
||||
shoppingPrices[priceKey] = { searched: true, product: res.product, spec: _cleanSpecForSearch(item.specification || '') };
|
||||
} else {
|
||||
shoppingPrices[priceKey] = { searched: true, product: null };
|
||||
}
|
||||
@@ -5564,6 +5754,161 @@ async function loadLog(more = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== WEEKLY MEAL PLAN =====
|
||||
|
||||
/**
|
||||
* All selectable meal categories per slot.
|
||||
* id must be URL-safe; icon + label shown in UI.
|
||||
*/
|
||||
const MEAL_PLAN_TYPES = [
|
||||
{ id: 'pasta', icon: '🍝', label: 'Pasta' },
|
||||
{ id: 'riso', icon: '🍚', label: 'Riso' },
|
||||
{ id: 'carne', icon: '🥩', label: 'Carne' },
|
||||
{ id: 'pesce', icon: '🐟', label: 'Pesce' },
|
||||
{ id: 'legumi', icon: '🫘', label: 'Legumi' },
|
||||
{ id: 'uova', icon: '🥚', label: 'Uova' },
|
||||
{ id: 'formaggio', icon: '🧀', label: 'Formaggio' },
|
||||
{ id: 'pizza', icon: '🍕', label: 'Pizza' },
|
||||
{ id: 'affettati', icon: '🥓', label: 'Affettati' },
|
||||
{ id: 'verdure', icon: '🥦', label: 'Verdure' },
|
||||
{ id: 'zuppa', icon: '🍲', label: 'Zuppa' },
|
||||
{ id: 'insalata', icon: '🥗', label: 'Insalata' },
|
||||
{ id: 'pane', icon: '🥪', label: 'Pane/Sandwich' },
|
||||
{ id: 'dolce', icon: '🍰', label: 'Dolce' },
|
||||
{ id: 'libero', icon: '🎲', label: 'Libero' },
|
||||
];
|
||||
|
||||
const MEAL_PLAN_TYPE_MAP = {};
|
||||
MEAL_PLAN_TYPES.forEach(t => { MEAL_PLAN_TYPE_MAP[t.id] = t; });
|
||||
|
||||
const WEEK_DAYS = ['Lunedì','Martedì','Mercoledì','Giovedì','Venerdì','Sabato','Domenica'];
|
||||
const WEEK_DAYS_SHORT = ['Lun','Mar','Mer','Gio','Ven','Sab','Dom'];
|
||||
|
||||
/** Default weekly plan as requested. */
|
||||
const DEFAULT_MEAL_PLAN = {
|
||||
1: { pranzo: 'pasta', cena: 'pesce' },
|
||||
2: { pranzo: 'riso', cena: 'carne' },
|
||||
3: { pranzo: 'legumi', cena: 'uova' },
|
||||
4: { pranzo: 'pasta', cena: 'pesce' },
|
||||
5: { pranzo: 'riso', cena: 'formaggio' },
|
||||
6: { pranzo: 'legumi', cena: 'pizza' },
|
||||
0: { pranzo: 'carne', cena: 'affettati' }, // 0 = Sunday (getDay())
|
||||
};
|
||||
|
||||
function getMealPlan() {
|
||||
const s = getSettings();
|
||||
return s.meal_plan || DEFAULT_MEAL_PLAN;
|
||||
}
|
||||
|
||||
/** Return today's planned meal type for a given slot ('pranzo'|'cena'), or null. */
|
||||
function getTodayMealPlanType(slot) {
|
||||
const s = getSettings();
|
||||
if (s.meal_plan_enabled === false) return null;
|
||||
const dow = new Date().getDay(); // 0=Sun,1=Mon,...,6=Sat
|
||||
const plan = getMealPlan();
|
||||
return plan[dow]?.[slot] || null;
|
||||
}
|
||||
|
||||
/** Toggle handler for the enable/disable switch in settings. */
|
||||
function onMealPlanEnabledChange(el) {
|
||||
const s = getSettings();
|
||||
s.meal_plan_enabled = el.checked;
|
||||
saveSettingsToStorage(s);
|
||||
const mpConfigSection = document.getElementById('meal-plan-config-section');
|
||||
if (mpConfigSection) mpConfigSection.style.display = el.checked ? '' : 'none';
|
||||
const mpLegendCard = document.getElementById('meal-plan-legend-card');
|
||||
if (mpLegendCard) mpLegendCard.style.display = el.checked ? '' : 'none';
|
||||
// Close picker if open
|
||||
const picker = document.getElementById('meal-plan-picker');
|
||||
if (picker) picker.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the weekly meal plan editor into #meal-plan-grid.
|
||||
* Each cell shows the current type badge + a picker dropdown.
|
||||
*/
|
||||
function renderMealPlanEditor() {
|
||||
const container = document.getElementById('meal-plan-grid');
|
||||
if (!container) return;
|
||||
const plan = getMealPlan();
|
||||
// JS getDay: 0=Sun … but we display Mon-Sun (1..6,0)
|
||||
const dayOrder = [1,2,3,4,5,6,0];
|
||||
const today = new Date().getDay();
|
||||
|
||||
const header = `<div class="mplan-header">
|
||||
<span class="mplan-col-header">🌤️ Pranzo</span>
|
||||
<span class="mplan-col-header">🌙 Cena</span>
|
||||
</div>`;
|
||||
|
||||
const rows = dayOrder.map((dow, i) => {
|
||||
const pranzo = plan[dow]?.pranzo || 'libero';
|
||||
const cena = plan[dow]?.cena || 'libero';
|
||||
const pt = MEAL_PLAN_TYPE_MAP[pranzo] || MEAL_PLAN_TYPE_MAP.libero;
|
||||
const ct = MEAL_PLAN_TYPE_MAP[cena] || MEAL_PLAN_TYPE_MAP.libero;
|
||||
const todayClass = dow === today ? ' mplan-row-today' : '';
|
||||
return `<div class="mplan-row${todayClass}">
|
||||
<div class="mplan-day-name">${WEEK_DAYS_SHORT[i]}</div>
|
||||
<span class="mplan-badge mplan-badge-pranzo" onclick="openMealPlanPicker(${dow},'pranzo',this)">${pt.icon} ${pt.label}</span>
|
||||
<span class="mplan-badge mplan-badge-cena" onclick="openMealPlanPicker(${dow},'cena',this)">${ct.icon} ${ct.label}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = header + rows;
|
||||
}
|
||||
|
||||
let _mplanPickerTarget = null; // {dow, slot, badgeEl}
|
||||
function openMealPlanPicker(dow, slot, badgeEl) {
|
||||
// Close any open picker first
|
||||
closeMealPlanPicker();
|
||||
_mplanPickerTarget = { dow, slot, badgeEl };
|
||||
const picker = document.getElementById('meal-plan-picker');
|
||||
if (!picker) return;
|
||||
const plan = getMealPlan();
|
||||
const current = plan[dow]?.[slot] || 'libero';
|
||||
picker.innerHTML = MEAL_PLAN_TYPES.map(t =>
|
||||
`<button class="mplan-pick-btn${t.id === current ? ' active' : ''}" onclick="selectMealPlanType(${dow},'${slot}','${t.id}')">${t.icon} ${t.label}</button>`
|
||||
).join('');
|
||||
// Position vertically near the badge, centered horizontally (CSS handles centering)
|
||||
const rect = badgeEl.getBoundingClientRect();
|
||||
const pickerEl = picker;
|
||||
// Show first to measure height
|
||||
pickerEl.style.display = 'flex';
|
||||
const pickerH = pickerEl.offsetHeight || 160;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const top = spaceBelow >= pickerH
|
||||
? rect.bottom + 8
|
||||
: Math.max(8, rect.top - pickerH - 8);
|
||||
pickerEl.style.top = top + 'px';
|
||||
// Close on outside tap
|
||||
setTimeout(() => document.addEventListener('click', _mplanPickerOutside, { once: true }), 0);
|
||||
}
|
||||
function _mplanPickerOutside(e) {
|
||||
const picker = document.getElementById('meal-plan-picker');
|
||||
if (picker && !picker.contains(e.target)) closeMealPlanPicker();
|
||||
}
|
||||
function closeMealPlanPicker() {
|
||||
const picker = document.getElementById('meal-plan-picker');
|
||||
if (picker) picker.style.display = 'none';
|
||||
_mplanPickerTarget = null;
|
||||
document.removeEventListener('click', _mplanPickerOutside);
|
||||
}
|
||||
function selectMealPlanType(dow, slot, typeId) {
|
||||
const s = getSettings();
|
||||
if (!s.meal_plan) s.meal_plan = JSON.parse(JSON.stringify(DEFAULT_MEAL_PLAN));
|
||||
if (!s.meal_plan[dow]) s.meal_plan[dow] = {};
|
||||
s.meal_plan[dow][slot] = typeId;
|
||||
saveSettingsToStorage(s);
|
||||
closeMealPlanPicker();
|
||||
renderMealPlanEditor();
|
||||
}
|
||||
function resetMealPlan() {
|
||||
const s = getSettings();
|
||||
s.meal_plan = JSON.parse(JSON.stringify(DEFAULT_MEAL_PLAN));
|
||||
saveSettingsToStorage(s);
|
||||
renderMealPlanEditor();
|
||||
showToast('Piano settimanale ripristinato', 'success');
|
||||
}
|
||||
|
||||
// ===== RECIPE GENERATION =====
|
||||
const MEAL_TYPES = [
|
||||
{ id: 'colazione', icon: '☀️', label: 'Colazione', from: 6, to: 11 },
|
||||
@@ -5710,6 +6055,9 @@ function openRecipeDialog() {
|
||||
}
|
||||
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';
|
||||
@@ -6377,6 +6725,7 @@ function removeCookingTimer(id) {
|
||||
if (t && t.interval) clearInterval(t.interval);
|
||||
_cookingTimers = _cookingTimers.filter(t => t.id !== id);
|
||||
renderTimersBar();
|
||||
_updateScreenFlash();
|
||||
}
|
||||
|
||||
function toggleCookingTimerById(id) {
|
||||
@@ -6431,6 +6780,25 @@ function _updateTimerCard(id) {
|
||||
}
|
||||
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() {
|
||||
@@ -6466,6 +6834,7 @@ function clearAllCookingTimers() {
|
||||
_cookingSuggestedLabel = '';
|
||||
const bar = document.getElementById('cooking-timers-bar');
|
||||
if (bar) { bar.style.display = 'none'; bar.innerHTML = ''; }
|
||||
_updateScreenFlash();
|
||||
}
|
||||
// ===== END COOKING TIMER SYSTEM =====
|
||||
|
||||
@@ -6511,6 +6880,48 @@ function cookingUseIngredient(idx, productId, location, qtyNumber, btn) {
|
||||
function updateRecipeMealTitle() {
|
||||
const meal = getSelectedMealType();
|
||||
document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || '🍳 Ricetta';
|
||||
_renderMealPlanHint(meal);
|
||||
}
|
||||
|
||||
/** Show/hide the meal-plan badge hint + top banner in the recipe dialog. */
|
||||
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 t = MEAL_PLAN_TYPE_MAP[typeId];
|
||||
if (!t) {
|
||||
if (el) el.style.display = 'none';
|
||||
if (banner) banner.style.display = 'none';
|
||||
if (chipWrap) chipWrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (el) {
|
||||
el.innerHTML = `<span class="mplan-hint-badge">${t.icon} ${t.label}</span> <span class="mplan-hint-label">suggerito dal piano settimanale</span>`;
|
||||
el.style.display = 'flex';
|
||||
}
|
||||
if (banner) {
|
||||
const slotLabel = mealSlot === 'pranzo' ? '🌤️ Pranzo' : '🌙 Cena';
|
||||
banner.innerHTML = `<span style="opacity:0.75;font-weight:500">${slotLabel}</span><span style="opacity:0.45">·</span><span>${t.icon} ${t.label}</span>`;
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
// Show the meal-plan chip (active by default, user can uncheck to ignore the plan)
|
||||
if (chipWrap) {
|
||||
chipWrap.style.display = '';
|
||||
if (chipLabel) chipLabel.textContent = `${t.icon} ${t.label}`;
|
||||
if (chipCb) chipCb.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateRecipe() {
|
||||
@@ -6535,6 +6946,15 @@ async function generateRecipe() {
|
||||
const meal = getSelectedMealType();
|
||||
const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
|
||||
const settings = getSettings();
|
||||
|
||||
// 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 = [];
|
||||
@@ -6562,7 +6982,8 @@ async function generateRecipe() {
|
||||
options,
|
||||
appliances: settings.appliances || [],
|
||||
dietary_restrictions: settings.dietary_restrictions || '',
|
||||
today_recipes: await getTodayRecipeTitles()
|
||||
today_recipes: await getTodayRecipeTitles(),
|
||||
meal_plan_type: mealPlanType,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -6817,6 +7238,25 @@ function updateScreensaverClock() {
|
||||
const date = now.toLocaleDateString('it-IT', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
const el = document.getElementById('screensaver-clock');
|
||||
if (el) el.innerHTML = `${time}<div class="screensaver-date">${date}</div>`;
|
||||
updateScreensaverMealPlan();
|
||||
}
|
||||
|
||||
/** Show/hide the planned meal type badge on the screensaver based on current time slot. */
|
||||
function updateScreensaverMealPlan() {
|
||||
const el = document.getElementById('screensaver-mealplan');
|
||||
if (!el) return;
|
||||
const s = getSettings();
|
||||
if (s.meal_plan_enabled === false) { el.style.display = 'none'; return; }
|
||||
const hour = new Date().getHours();
|
||||
// Before 15:00 show pranzo, from 15:00 onwards show cena
|
||||
const slot = hour < 15 ? 'pranzo' : 'cena';
|
||||
const typeId = getTodayMealPlanType(slot);
|
||||
if (!typeId || typeId === 'libero') { el.style.display = 'none'; return; }
|
||||
const t = MEAL_PLAN_TYPE_MAP[typeId];
|
||||
if (!t) { el.style.display = 'none'; return; }
|
||||
const slotLabel = slot === 'pranzo' ? '🌤️ Pranzo' : '🌙 Cena';
|
||||
el.innerHTML = `<span class="screensaver-mealplan-badge">${slotLabel} · ${t.icon} ${t.label}</span>`;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function dismissScreensaver(targetPage) {
|
||||
@@ -7315,14 +7755,107 @@ function initInactivityWatcher() {
|
||||
|
||||
// ===== INITIALIZATION =====
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Migrate old session-based flags to time-based
|
||||
if (sessionStorage.getItem('_autoAddedCritical')) {
|
||||
sessionStorage.removeItem('_autoAddedCritical');
|
||||
}
|
||||
// One-time reset of bg sync timestamp so first load always triggers a sync
|
||||
if (!localStorage.getItem('_bgBringSyncReset_v1')) {
|
||||
localStorage.removeItem('_bgBringSyncTs');
|
||||
localStorage.setItem('_bgBringSyncReset_v1', '1');
|
||||
}
|
||||
syncSettingsFromDB();
|
||||
showPage('dashboard');
|
||||
initInactivityWatcher();
|
||||
initSpesaMode();
|
||||
initScreensaverShortcuts();
|
||||
startBgShoppingRefresh();
|
||||
|
||||
// Auto-refresh Bring list when user returns to the browser tab (e.g. was in the Bring app)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && _currentPageId === 'shopping') {
|
||||
loadShoppingList();
|
||||
}
|
||||
});
|
||||
|
||||
// Silent background sync: update urgency specs on Bring and add missing critical items
|
||||
// Runs once at startup (time-gated: max every 10 min) without affecting the UI
|
||||
_backgroundBringSync();
|
||||
});
|
||||
|
||||
/**
|
||||
* Background sync at startup:
|
||||
* 1. Fetches Bring list + smart shopping in parallel
|
||||
* 2. Adds any critical items missing from Bring
|
||||
* 3. Updates urgency specs for items already on Bring that need it
|
||||
* Fully silent — no toasts, no loading spinners.
|
||||
*/
|
||||
async function _backgroundBringSync() {
|
||||
const lastRun = parseInt(localStorage.getItem('_bgBringSyncTs') || '0');
|
||||
if (Date.now() - lastRun < 10 * 60 * 1000) return;
|
||||
localStorage.setItem('_bgBringSyncTs', String(Date.now()));
|
||||
|
||||
try {
|
||||
const [bringData, smartData] = await Promise.all([
|
||||
api('bring_list').catch(() => null),
|
||||
api('smart_shopping').catch(() => null),
|
||||
]);
|
||||
|
||||
if (!bringData?.success || !smartData?.success) return;
|
||||
|
||||
const listUUID = bringData.listUUID;
|
||||
const bringItems = bringData.purchase || [];
|
||||
const smartItems = smartData.items || [];
|
||||
|
||||
if (!listUUID || !smartItems.length) return;
|
||||
|
||||
// Update local smart cache so other functions can use it
|
||||
if (!smartShoppingItems.length) {
|
||||
smartShoppingItems = smartItems;
|
||||
_smartShoppingLastFetch = Date.now();
|
||||
}
|
||||
if (!shoppingListUUID) shoppingListUUID = listUUID;
|
||||
if (!shoppingItems.length) shoppingItems = bringItems;
|
||||
|
||||
const toAdd = []; // new items not yet on Bring
|
||||
const toUpdate = []; // items on Bring that need spec updated
|
||||
|
||||
for (const si of smartItems) {
|
||||
if (si.urgency === 'none' || si.urgency === 'low') continue;
|
||||
const expectedSpec = _urgencyToSpec(si.urgency, '');
|
||||
const bringMatch = bringItems.find(bi => {
|
||||
const biL = bi.name.toLowerCase();
|
||||
const siL = si.name.toLowerCase();
|
||||
if (biL === siL) return true;
|
||||
const biFirst = _nameTokens(bi.name)[0];
|
||||
const siFirst = _nameTokens(si.name)[0];
|
||||
return biFirst && biFirst === siFirst;
|
||||
});
|
||||
|
||||
if (!bringMatch) {
|
||||
// Not on Bring — add if critical
|
||||
if (si.urgency === 'critical') {
|
||||
toAdd.push({ name: si.name, specification: expectedSpec });
|
||||
}
|
||||
} else {
|
||||
// On Bring — update spec if urgency marker is missing/wrong
|
||||
const currentSpec = (bringMatch.specification || '').toLowerCase();
|
||||
const hasUrgencyMarker = currentSpec.includes('urgente') || currentSpec.includes('presto');
|
||||
if (!hasUrgencyMarker && expectedSpec) {
|
||||
toUpdate.push({ name: si.name, specification: expectedSpec, update_spec: true });
|
||||
bringMatch.specification = expectedSpec; // update local copy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allChanges = [...toAdd, ...toUpdate];
|
||||
if (allChanges.length === 0) return;
|
||||
|
||||
await api('bring_add', {}, 'POST', { items: allChanges, listUUID });
|
||||
logOperation('bg_bring_sync', { added: toAdd.map(i=>i.name), updated: toUpdate.map(i=>i.name) });
|
||||
} catch (e) { /* silent — best effort */ }
|
||||
}
|
||||
|
||||
// ===== DUPLICLICK (SPESA ONLINE) =====
|
||||
|
||||
function selectSpesaProvider(btn, provider) {
|
||||
|
||||
Reference in New Issue
Block a user