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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user