feat: AI price estimation for shopping list with per-item real-time display

- Add get_shopping_price / get_all_shopping_prices API endpoints
- AI (Gemini) estimates retail price per natural unit (pack, piece, bunch)
  instead of always per-kg — avoids absurd totals like €1609
- _calcEstimatedTotal: proper g/ml→package conversion using defQty + regex
  on unit_label; only 'kg'/'l' labels trigger weight/volume math
- Cache key bumped to v2 to invalidate old per-kg cached entries
- Suggested quantity cap lowered from 20 to 10 conf/pz
- Unit mismatch guard: if totalUsed >> buyCount*5 for unit=conf, use
  purchase frequency instead of raw consumption rate
- JS _buildPricePayload: use smartShoppingItems for qty/unit (not Bring! spec)
- JS _cachedPrices: persist in sessionStorage (survives navigation);
  validated by _qty/_unit metadata so stale totals auto-invalidate
- Price display redesigned: right-side column per row (price-col-main +
  price-col-unit) instead of small inline badge
- fetchAllPrices: buttons disabled immediately before guard check;
  running total uses only current shoppingItems (not Object.values cache)
- Background refresh: always silent (removed 90s interaction condition)
- visibilitychange: sets _bgCall=true for shopping before refreshCurrentPage
- .gitignore: add runtime data files (bring_migrate_ts, shopping_price_cache,
  anomaly_dismissed, opened_shelf_cache, shopping_name_cache)
- Remove bring_catalog.json and bring_migrate_ts.json from tracking
This commit is contained in:
dadaloop82
2026-05-07 17:31:23 +00:00
parent 4196130835
commit 5f510c0451
11 changed files with 1249 additions and 743 deletions
+133
View File
@@ -516,6 +516,24 @@ body {
color: var(--primary);
}
/* Skeleton shimmer while stat card data is loading */
@keyframes stat-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.stat-value.stat-loading {
color: transparent !important;
background: linear-gradient(90deg, var(--border) 25%, color-mix(in srgb, var(--border) 40%, white) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: stat-shimmer 1.2s ease-in-out infinite;
border-radius: 8px;
min-width: 40px;
min-height: 2.4rem;
display: inline-block;
vertical-align: middle;
user-select: none;
}
.stat-label {
font-size: 0.85rem;
color: var(--text-light);
@@ -1861,6 +1879,121 @@ body {
.badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; }
.badge-local-tag:hover { background: #bae6fd; }
/* ─── Shopping price badge ─── */
.shopping-item-price-badge {
margin-top: 4px;
min-height: 1.2rem;
}
.price-badge-value {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
color: #15803d;
background: #dcfce7;
border-radius: 8px;
padding: 1px 7px;
white-space: nowrap;
}
.price-badge-loading {
display: inline-block;
font-size: 0.7rem;
color: #94a3b8;
font-style: italic;
}
.price-badge-error {
display: inline-block;
font-size: 0.65rem;
color: #94a3b8;
}
/* ─── Price column — right-side per-item price display ─── */
.shopping-item-price-col {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
flex-shrink: 0;
min-width: 58px;
text-align: right;
padding: 0 4px;
}
.price-col-main {
font-size: 1.05rem;
font-weight: 700;
color: #15803d;
white-space: nowrap;
line-height: 1.25;
}
.price-col-unit {
font-size: 0.62rem;
color: #94a3b8;
white-space: nowrap;
line-height: 1.2;
margin-top: 1px;
}
.price-col-loading {
font-size: 0.7rem;
color: #cbd5e1;
font-style: italic;
}
.price-col-error {
font-size: 0.75rem;
color: #cbd5e1;
}
/* ─── Price summary bar (top of shopping tab) ─── */
.shopping-price-total-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 4px;
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
border-radius: 10px;
margin: 8px 0 4px;
font-size: 0.88rem;
font-weight: 600;
color: #166534;
box-shadow: 0 1px 4px rgba(21,128,61,0.08);
}
.price-total-label {
flex: 1;
}
.price-total-value {
font-size: 1rem;
font-weight: 700;
}
.btn-price-refresh {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
padding: 2px 6px;
border-radius: 6px;
transition: background 0.15s;
}
.btn-price-refresh:hover { background: rgba(0,0,0,0.07); }
.btn-price-refresh:disabled { opacity: 0.5; cursor: default; }
/* Loading progress bar */
.price-loading-bar {
height: 3px;
background: #dcfce7;
border-radius: 2px;
overflow: hidden;
margin: 0 12px 6px;
}
.price-loading-inner {
height: 100%;
width: 0%;
background: #16a34a;
border-radius: 2px;
transition: width 0.3s ease;
}
@keyframes price-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
/* Tag add button */
.shopping-item-tag-btn {
background: none;
+547 -55
View File
@@ -1258,34 +1258,35 @@ function guessCategoryFromName(name) {
if (!name) return 'altro';
const n = name.toLowerCase();
// Pasta & Rice
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio/.test(n)) return 'pasta';
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette/.test(n)) return 'pasta';
// Pane & Forno
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli/.test(n)) return 'pane';
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli|pancarrè|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b/.test(n)) return 'pane';
// Latticini (before bevande to avoid latte→bevande)
if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie|camembert|emmental|asiago|feta\b|provola|caciotta|caprino/.test(n)) return 'latticini';
// Conserve
if (/passata|pelati|pomodoro|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive/.test(n)) return 'conserve';
if (/passata|pelati|pomodoro|pomodori|pomodorini|ciliegino|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive|tonno in scatola|sgombro in scatola|concentrato|brodo|dado|besciamella/.test(n)) return 'conserve';
// Condimenti (include spezie, farine, zucchero)
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa|paprika|curry|cannella|noce moscata|origano|rosmarino|timo|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido|maizena|semola/.test(n)) return 'condimenti';
// Bevande
if (/acqua|birra|vino|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|latte\b/.test(n)) return 'bevande';
// Latticini (include eggs/uova)
if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg/.test(n)) return 'latticini';
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa|paprika|curry|cannella|noce moscata|origano|rosmarino|timo|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido|maizena|semola|pesto|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco/.test(n)) return 'condimenti';
// Bevande (after latticini to avoid latte conflict)
if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino/.test(n)) return 'bevande';
// Carne (include salumi)
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia/.test(n)) return 'carne';
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola/.test(n)) return 'carne';
// Pesce
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi/.test(n)) return 'pesce';
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi|alici|branzino|orata|sardine|trota|dentice|seppia|polpo/.test(n)) return 'pesce';
// Frutta
if (/mela|mele|banana|arancia|pera|fragola|uva|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca|nettarina|fico|melograno/.test(n)) return 'frutta';
if (/mela|mele|banana|arancia|pera|fragola|uva\b|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|fico\b|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta';
// Verdura
if (/insalata|zucchina|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|zucca|aglio/.test(n)) return 'verdura';
if (/insalata|zucchina|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|zucca|aglio|cetriolo|rapa|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura';
// Surgelati
if (/surgelat|frozen|findus|4.salti|gelato/.test(n)) return 'surgelati';
// Snack
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini/.test(n)) return 'snack';
if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato/.test(n)) return 'surgelati';
// Snack & Dolci
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini|torta|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramisù/.test(n)) return 'snack';
// Cereali
if (/cereali|muesli|fiocchi|granola|polenta/.test(n)) return 'cereali';
// Igiene / Pulizia
if (/sapone|shampoo|dentifricio|deodorante/.test(n)) return 'igiene';
if (/detersivo|pulito|sgrassatore/.test(n)) return 'pulizia';
if (/cereali|muesli|fiocchi|granola|polenta|porridge|avena/.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|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia';
return 'altro';
}
@@ -1871,26 +1872,9 @@ function debounce(fn, ms) {
async function syncSettingsFromDB() {
try {
// Primary: load from server .env
// Primary: load from server .env (only when not already done via _applySyncedSettings)
const serverSettings = await api('get_settings');
_geminiAvailable = !!(serverSettings.gemini_key_set);
_demoMode = !!serverSettings.demo_mode;
_updateGeminiButtonState();
_applyDemoModeUI();
const s = getSettings();
const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze',
'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances',
'camera_facing','scale_enabled','scale_gateway_url',
'meal_plan_enabled','tts_enabled','tts_url','tts_token',
'tts_method','tts_auth_type','tts_content_type','tts_payload_key',
'screensaver_enabled','screensaver_timeout'];
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
}
}
_settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
_applySyncedSettings(serverSettings);
// Also load review_confirmed from DB
const res = await api('app_settings_get');
if (res.success && res.settings) {
@@ -1901,6 +1885,37 @@ async function syncSettingsFromDB() {
} catch(e) { /* offline, use local */ }
}
/**
* Apply server settings object into localStorage cache.
* 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',
'screensaver_enabled','screensaver_timeout',
'price_enabled','price_country','price_currency','price_update_months'];
let changed = false;
for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key];
changed = true;
}
}
if (changed) {
_settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
}
}
async function loadSettingsUI() {
const s = getSettings();
document.getElementById('setting-gemini-key').value = s.gemini_key || '';
@@ -1993,7 +2008,8 @@ async function loadSettingsUI() {
'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_content_type','tts_payload_key',
'price_enabled','price_country','price_currency','price_update_months'];
// Note: gemini_key is never sent from server; settings_token_set is metadata only
const settingsTokenRequired = !!serverSettings.settings_token_set;
const tokenHintEl = document.getElementById('settings-token-status-hint');
@@ -2035,8 +2051,34 @@ async function loadSettingsUI() {
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;
}
} 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;
@@ -2358,6 +2400,15 @@ async function saveSettings() {
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;
saveSettingsToStorage(s);
// Save ALL settings to server .env
@@ -2389,6 +2440,10 @@ async function saveSettings() {
tts_auth_type: s.tts_auth_type,
tts_content_type: s.tts_content_type,
tts_payload_key: s.tts_payload_key,
price_enabled: s.price_enabled,
price_country: s.price_country,
price_currency: s.price_currency,
price_update_months: s.price_update_months,
}, tokenHeader);
const statusEl = document.getElementById('settings-status');
if (result.success) {
@@ -2481,7 +2536,10 @@ function refreshCurrentPage() {
switch(_currentPageId) {
case 'dashboard': loadDashboard(); break;
case 'inventory': loadInventory(); break;
case 'shopping': loadShoppingList(); break;
case 'shopping':
loadShoppingList._bgCall = true;
loadShoppingList();
break;
case 'products': loadAllProducts(); break;
case 'recipe': loadRecipeArchive(); break;
case 'log': loadLog(); break;
@@ -3117,6 +3175,12 @@ function _applyInsightPhase() {
// ===== DASHBOARD =====
async function loadDashboard() {
// Show shimmer on stat cards while loading
['stat-dispensa', 'stat-frigo', 'stat-freezer'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.add('stat-loading');
});
try {
const [summaryData, statsData] = await Promise.all([
api('inventory_summary'),
@@ -3131,6 +3195,7 @@ async function loadDashboard() {
const count = s ? s.product_count : 0;
const el = document.getElementById(`stat-${loc}`);
el.textContent = count;
el.classList.remove('stat-loading');
total += count;
});
// Add non-standard locations
@@ -3218,8 +3283,11 @@ async function loadDashboard() {
// Banner alerts (suspicious quantities + consumption predictions)
loadBannerAlerts();
// Anti-waste section (load facts first so rotation has full dataset)
await _awLoadFacts();
// Anti-waste section + Nutrition section: load in parallel
const [, invForNutr] = await Promise.all([
_awLoadFacts(),
api('inventory_list').then(d => d.inventory || []).catch(() => []),
]);
_renderAntiWasteSection(
statsData.used_30d || 0, statsData.wasted_30d || 0,
statsData.used_prev_30d || 0, statsData.wasted_prev_30d || 0,
@@ -3229,10 +3297,7 @@ async function loadDashboard() {
_startAntiWasteAutoRefresh();
// Nutrition section — built from the full inventory list
try {
const invForNutr = (await api('inventory_list')).inventory || [];
_renderNutritionSection(invForNutr);
} catch(_e) {}
_renderNutritionSection(invForNutr);
_startInsightAlternation();
// Opened (partially used products with known package capacity)
@@ -3334,6 +3399,11 @@ async function loadDashboard() {
} catch (err) {
console.error('Dashboard load error:', err);
// Remove shimmer even on error so numbers don't disappear forever
['stat-dispensa', 'stat-frigo', 'stat-freezer'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.classList.remove('stat-loading'); if (el.textContent === '') el.textContent = '-'; }
});
}
}
@@ -3546,8 +3616,12 @@ async function loadBannerAlerts() {
if (noExpiryDismissed[pid]) return; // user said "no expiry needed"
// Only flag perishable-looking categories or items with opened_at
const cat = (item.category || '').toLowerCase();
// Also infer category from name for items with missing/generic category
const guessedCat = guessCategoryFromName(item.name || '');
const perishableGuessed = ['latticini','carne','pesce','frutta','verdura','surgelati'].includes(guessedCat);
const likelyPerishable = item.opened_at ||
PERISHABLE_CATS.some(c => cat.includes(c));
PERISHABLE_CATS.some(c => cat.includes(c)) ||
perishableGuessed;
if (!likelyPerishable) return;
_bannerQueue.push({ type: 'no_expiry', data: item });
});
@@ -8498,6 +8572,7 @@ function toggleShoppingTag(itemIdx, tag) {
// ===== 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 };
@@ -8660,6 +8735,284 @@ async function forceSyncBring() {
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 */
// Price cache persisted in sessionStorage — survives SPA navigation, cleared on tab close.
// Each entry includes _qty/_unit metadata so stale estimated_totals auto-invalidate when qty changes.
let _cachedPrices = (() => {
try { return JSON.parse(sessionStorage.getItem('_pricecache') || '{}'); } catch { return {}; }
})();
/**
* 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) => {
// Look up the matching smart shopping item to get reliable qty/unit data.
// Bring! spec strings can be stale or free-text — don't trust them for calculations.
const nameLower = item.name.toLowerCase();
const smart = (smartShoppingItems || []).find(s =>
s.name.toLowerCase() === nameLower ||
(s.shopping_name || '').toLowerCase() === nameLower
);
let quantity = smart?.suggested_qty || 1;
let unit = smart?.suggested_unit || smart?.unit || 'pz';
let default_quantity = smart?.default_qty || 0;
let package_unit = smart?.package_unit || '';
// If no smart match, fall back to parsing the Bring! spec (last resort)
if (!smart) {
const spec = item.specification || '';
const qtyMatch = spec.match(/(\d+(?:[.,]\d+)?)\s*(g|kg|ml|l|pz|conf|lt|liter|litre)\b/i);
if (qtyMatch) {
quantity = parseFloat(qtyMatch[1].replace(',', '.'));
unit = qtyMatch[2].toLowerCase();
}
}
return { name: item.name, quantity, unit, default_quantity, package_unit };
});
}
/**
* 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 mainLabel = entry.estimated_total != null
? `${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 || '';
return `<div class="price-col-main" title="${escapeHtml(title)}">${mainLabel}</div>`
+ (unitLine ? `<div class="price-col-unit">${unitLine}</div>` : '');
}
/**
* 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 = `<span class="price-col-error"></span>`;
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 (fetchBtn) fetchBtn.disabled = true;
if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = '⏳'; }
if (_pricesFetching) return; // already running — buttons will be re-enabled by active fetch
if (!shoppingItems.length) {
if (fetchBtn) fetchBtn.disabled = false;
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
return;
}
const s = getSettings();
if (!s.price_enabled) {
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';
if (forceRefresh) {
// Full refresh: clear in-memory + sessionStorage cache, reset all badges to loading
_cachedPrices = {};
try { sessionStorage.removeItem('_pricecache'); } catch { /* ignore */ }
shoppingItems.forEach((_, idx) => {
const badge = document.getElementById(`price-badge-${idx}`);
if (badge) badge.innerHTML = `<span class="price-col-loading">…</span>`;
});
if (totalEl) totalEl.textContent = t('shopping.price_loading');
if (loadingBar) loadingBar.style.display = 'block';
if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '0%'; }
} else {
// Incremental: apply cached prices instantly, mark uncached as loading
const { total: cachedTotal, count: cachedCount } = _applyPriceBadgesFromCache();
shoppingItems.forEach((item, idx) => {
if (!_cachedPrices[item.name]) {
const badge = document.getElementById(`price-badge-${idx}`);
if (badge) badge.innerHTML = `<span class="price-col-loading">…</span>`;
}
});
const uncachedCount = shoppingItems.filter(i => !_cachedPrices[i.name]).length;
if (uncachedCount === 0) {
// All already cached — just show total and done
if (totalEl && cachedCount > 0) totalEl.textContent = `ca. ${_currencySymbol(s.price_currency || 'EUR')}${cachedTotal.toFixed(2)}`;
_pricesFetching = false;
if (fetchBtn) fetchBtn.disabled = false;
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
return;
}
if (totalEl && cachedCount > 0) totalEl.textContent = `ca. ${_currencySymbol(s.price_currency || 'EUR')}${cachedTotal.toFixed(2)}`;
if (loadingBar) loadingBar.style.display = 'block';
if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '0%'; }
}
const lang = s.language || 'it';
const country = s.price_country || 'Italia';
const currency = s.price_currency || 'EUR';
const sym = _currencySymbol(currency);
const items = _buildPricePayload();
const total = items.length;
// Running totals: only count items in the CURRENT shopping list with matching qty
let runningTotal = shoppingItems.reduce((sum, item) => {
const e = _cachedPrices[item.name];
const pi = items.find(x => x.name === item.name);
if (!e || !pi || e._qty !== pi.quantity || e._unit !== pi.unit) return sum;
return sum + (e?.estimated_total || 0);
}, 0);
let pricesFound = shoppingItems.filter(i => {
const e = _cachedPrices[i.name];
const pi = items.find(x => x.name === i.name);
return e && pi && e._qty === pi.quantity && e._unit === pi.unit && e.estimated_total != null;
}).length;
let processed = 0;
try {
for (let i = 0; i < items.length; i++) {
if (!_pricesFetching) break; // guard: list was reloaded mid-fetch
const item = items[i];
const badge = document.getElementById(`price-badge-${i}`);
// Skip if already cached with same qty/unit (and not forceRefresh)
const cached = _cachedPrices[item.name];
if (!forceRefresh && cached && cached._qty === item.quantity && cached._unit === item.unit) {
processed++;
const progress = Math.round((processed / total) * 100);
if (loadingInner) { loadingInner.style.transition = 'width 0.3s ease'; loadingInner.style.width = `${progress}%`; }
continue;
}
try {
const data = await api('get_shopping_price', {}, 'POST', {
...item, country, currency, lang, force_refresh: forceRefresh,
});
if (data && data.success) {
_cachedPrices[item.name] = { ...data, _qty: item.quantity, _unit: item.unit };
if (badge) badge.innerHTML = _buildPriceBadgeHTML(data, sym);
if (data.estimated_total != null) {
runningTotal += data.estimated_total;
pricesFound++;
}
} else {
if (badge) badge.innerHTML = `<span class="price-col-error"></span>`;
}
} catch (_err) {
if (badge) badge.innerHTML = `<span class="price-col-error"></span>`;
}
processed++;
const progress = Math.round((processed / total) * 100);
if (loadingInner) { loadingInner.style.transition = 'width 0.3s ease'; loadingInner.style.width = `${progress}%`; }
if (totalEl) {
totalEl.textContent = pricesFound > 0
? `ca. ${sym}${runningTotal.toFixed(2)}`
: t('shopping.price_loading');
}
}
} finally {
_pricesFetching = false;
// Persist to sessionStorage so prices survive page navigation
try { sessionStorage.setItem('_pricecache', JSON.stringify(_cachedPrices)); } catch { /* quota */ }
if (loadingBar) loadingBar.style.display = 'none';
if (totalEl) totalEl.textContent = pricesFound > 0 ? `ca. ${sym}${runningTotal.toFixed(2)}` : '';
if (fetchBtn) fetchBtn.disabled = false;
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
}
}
/**
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.
localStorage.removeItem('_bringPurchasedBlocklist');
localStorage.removeItem('_autoAddedCriticalTs');
localStorage.removeItem('_bringCleanupTs');
localStorage.removeItem('_autoAddedBring');
logOperation('force_sync_bring', {});
// Reload everything from scratch
await loadShoppingList();
if (btn) { btn.disabled = false; btn.textContent = `🔄 ${t('shopping.force_sync')}`; }
showToast(`🔄 ${t('shopping.sync_done')}`, 'success');
}
/**
* One-time cleanup: remove items from Bring! that were auto-added but the algorithm no
* longer considers relevant. CONSERVATIVE: only removes items that match a known product
@@ -8912,7 +9265,8 @@ async function loadSmartShopping() {
);
smartShoppingItems = data.items;
_smartShoppingLastFetch = Date.now();
// If the set of critical items changed, reset autoAdd/cleanup timers so
// 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)) ||
@@ -9187,17 +9541,23 @@ async function addSmartToBring() {
// 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('bring_list');
const el = document.getElementById('stat-spesa');
if (data.success && data.purchase) {
el.textContent = data.purchase.length;
} else {
el.textContent = '-';
if (el) {
if (data.success && data.purchase) {
el.textContent = data.purchase.length;
} else {
el.textContent = '-';
}
el.classList.remove('stat-loading');
}
} catch {
const el = document.getElementById('stat-spesa');
el.textContent = '-';
if (el) {
el.textContent = '-';
el.classList.remove('stat-loading');
}
}
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
@@ -9321,6 +9681,36 @@ 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('bring_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();
}
loadShoppingCount();
}
} catch(_e) {}
return;
}
statusEl.style.display = 'block';
statusEl.innerHTML = `<div class="bring-loading"><div class="loading-spinner"></div> ${t('shopping.bring_loading')}</div>`;
@@ -9374,6 +9764,10 @@ async function loadShoppingList() {
if (removedNames.length) _markBringPurchased(removedNames);
}
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();
@@ -9515,6 +9909,8 @@ async function renderShoppingItems() {
).join('')}
</div>`;
const priceEnabled = getSettings().price_enabled;
html += `
<div class="shopping-item" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="${t('shopping.tap_to_scan')}"${bgStyle}>
<span class="shopping-item-icon">${catIcon}</span>
@@ -9528,6 +9924,7 @@ async function renderShoppingItems() {
${_specDisplayText(item.specification) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''}
${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''}
</div>
${priceEnabled ? `<div class="shopping-item-price-col" id="price-badge-${idx}"><span class="price-col-loading">…</span></div>` : ''}
<div class="shopping-item-right" onclick="event.stopPropagation()">
<button class="shopping-item-tag-btn" onclick="toggleShoppingTagMenu(this)" title="${t('shopping.tag_title')}">🏷</button>
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="${t('shopping.remove_title')}"></button>
@@ -9540,9 +9937,26 @@ async function renderShoppingItems() {
}
container.innerHTML = html;
// 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;
// Immediately apply any prices already fetched this session — no flicker, no loading bar
_applyPriceBadgesFromCache();
// Fetch only items not yet priced (or all if none are cached yet)
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';
@@ -9552,6 +9966,7 @@ function toggleShoppingTagMenu(btn) {
}
async function removeBringItem(idx) {
loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list
const item = shoppingItems[idx];
if (!item) return;
try {
@@ -10074,6 +10489,51 @@ async function _doUndoTransaction(id, type, name) {
// ===== 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.
@@ -10157,6 +10617,21 @@ function onMealPlanEnabledChange(el) {
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.
@@ -11572,6 +12047,17 @@ function _renderMealPlanHint(mealSlot) {
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 regenerateRecipe() {
@@ -12841,6 +13327,8 @@ async function _initApp() {
if (missing.length > 0 && !_demoMode) {
showSetupWizard(missing);
}
// Reuse the already-fetched serverSettings to avoid a second get_settings request
_applySyncedSettings(serverSettings);
}
// Migrate old session-based flags to time-based
@@ -12852,6 +13340,7 @@ async function _initApp() {
localStorage.removeItem('_bgBringSyncTs');
localStorage.setItem('_bgBringSyncReset_v1', '1');
}
// syncSettingsFromDB only needs to fetch app_settings_get for review flags now
syncSettingsFromDB().then(() => {
scaleInit(); // connect to smart scale gateway if configured (needs settings)
initInactivityWatcher();
@@ -12883,6 +13372,7 @@ async function _initApp() {
setInterval(() => {
if (_screensaverActive) return;
if (_currentPageId === 'shopping') {
loadShoppingList._bgCall = true;
loadShoppingList();
} else {
loadShoppingCount();
@@ -12892,6 +13382,8 @@ async function _initApp() {
// 3) Aggiorna immediatamente quando la tab torna visibile (es. torni da Bring! app)
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// Always treat visibility restore as a background call for shopping
if (_currentPageId === 'shopping') loadShoppingList._bgCall = true;
refreshCurrentPage();
_checkWebappUpdate(); // also check for app updates when user returns to tab
}