/**
* Dispensa Manager - Main Application JS
* Complete pantry management with barcode scanning and AI identification
*/
// ===== CONFIGURATION =====
const API_BASE = 'api/index.php';
const LOCATIONS = {
'dispensa': { icon: 'ποΈ', label: 'Dispensa' },
'frigo': { icon: 'π§', label: 'Frigo' },
'freezer': { icon: 'βοΈ', label: 'Freezer' },
'altro': { icon: 'π¦', label: 'Altro' },
};
const CATEGORY_ICONS = {
'latticini': 'π₯', 'carne': 'π₯©', 'pesce': 'π', 'frutta': 'π',
'verdura': 'π₯¬', 'pasta': 'π', 'pane': 'π', 'surgelati': 'π§',
'bevande': 'π₯€', 'condimenti': 'π§', 'snack': 'πͺ', 'conserve': 'π₯«',
'cereali': 'πΎ', 'igiene': 'π§΄', 'pulizia': 'π§Ή', 'altro': 'π¦'
};
// Auto-detect location based on category and product name
const CATEGORY_LOCATION = {
'latticini': 'frigo', 'carne': 'frigo', 'pesce': 'frigo',
'frutta': 'frigo', 'verdura': 'frigo', 'surgelati': 'freezer',
'pasta': 'dispensa', 'pane': 'dispensa', 'bevande': 'dispensa',
'condimenti': 'dispensa', 'snack': 'dispensa', 'conserve': 'dispensa',
'cereali': 'dispensa', 'igiene': 'altro', 'pulizia': 'altro', 'altro': 'dispensa'
};
// Map Open Food Facts categories to local categories
function mapToLocalCategory(ofCategory, productName) {
if (!ofCategory) {
// No category tag β try to guess from product name
return guessCategoryFromName(productName || '');
}
const cat = ofCategory.toLowerCase();
// Direct match with our local keys
for (const key of Object.keys(CATEGORY_ICONS)) {
if (cat === key) return key;
}
// Handle specific Open Food Facts tags FIRST (before generic regex)
// "plant-based-foods-and-beverages" is a catch-all β use product name to decide
if (/plant-based-foods/.test(cat)) {
return guessCategoryFromName(productName || '');
}
// "beverages-and-beverages-preparations" = actual beverages
if (/^en:beverages/.test(cat)) return 'bevande';
// sweeteners = condimenti
if (/sweetener|dolcific/.test(cat)) return 'condimenti';
// Specific tag patterns
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte/.test(cat)) return 'latticini';
if (/meat|viande|carne|sausage|salum|prosciutt/.test(cat)) return 'carne';
if (/fish|poisson|pesce|seafood|tuna|tonno|salmone/.test(cat)) return 'pesce';
if (/fruit|frutta|juice|succo|apple|banana/.test(cat)) return 'frutta';
if (/vegetable|verdur|legum|salad|insalat|tomato|pomodor/.test(cat)) return 'verdura';
if (/pasta|rice|riso|noodle|spaghetti|penne|grain/.test(cat)) return 'pasta';
if (/bread|pane|forno|biscott|toast|cracker|grissini|fette/.test(cat)) return 'pane';
if (/frozen|surgelΓ©|surgel|gelat/.test(cat)) return 'surgelati';
if (/sauce|condiment|oil|olio|vinegar|aceto|mayo|ketchup|spice|salt|sugar|zuccher/.test(cat)) return 'condimenti';
if (/snack|chip|crisp|chocolate|cioccolat|candy|biscuit|cookie|wafer|merendine|patatine/.test(cat)) return 'snack';
if (/conserve|canned|can|pelati|passata|preserve|jam|marmellat|miele|honey/.test(cat)) return 'conserve';
if (/cereal|muesli|granola|oat|fiocchi/.test(cat)) return 'cereali';
if (/hygiene|soap|shampoo|igien|dentifricio|deodorant/.test(cat)) return 'igiene';
if (/clean|detergent|pulizia|detersiv/.test(cat)) return 'pulizia';
// Beverage check LAST (to avoid false matches on compound tags)
if (/^(?!.*plant-based).*(beverage|drink|boisson|bevand|water|acqua|beer|birra|wine|vino|coffee|caffè|tea\b)/.test(cat)) return 'bevande';
return 'altro';
}
// Guess a local category purely from product name
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';
// Pane & Forno
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli/.test(n)) return 'pane';
// Conserve
if (/passata|pelati|pomodoro|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive/.test(n)) return 'conserve';
// Condimenti
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa/.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
if (/latte\b|yogurt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b/.test(n)) return 'latticini';
// Carne
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck/.test(n)) return 'carne';
// Pesce
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe/.test(n)) return 'pesce';
// Frutta
if (/mela|mele|banana|arancia|pera|fragola|uva|kiwi|limone|frutta/.test(n)) return 'frutta';
// Verdura
if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata/.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';
// 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';
return 'altro';
}
// Determine safety level for expired products
// Returns { level: 'danger'|'warning'|'ok', icon, label, tip }
function getExpiredSafety(item, daysExpired) {
const cat = mapToLocalCategory(item.category || '', item.name || '');
const loc = (item.location || '').toLowerCase();
const name = (item.name || '').toLowerCase();
// HIGH RISK categories - perishable, can be dangerous
// Latticini freschi, carne, pesce, verdura, frutta
const highRisk = ['latticini', 'carne', 'pesce', 'verdura', 'frutta'];
// MEDIUM RISK - check before consuming
// Pane, surgelati, bevande (fresh juices, milk)
const medRisk = ['pane', 'surgelati'];
// Items in frigo are more perishable
const inFrigo = loc === 'frigo';
if (highRisk.includes(cat)) {
if (daysExpired <= 2 && inFrigo) {
return { level: 'warning', icon: 'π', label: 'Controlla', tip: 'Scaduto da poco, controlla odore e aspetto prima di consumare' };
}
return { level: 'danger', icon: 'ποΈ', label: 'Buttare', tip: 'Prodotto deperibile scaduto: da buttare per sicurezza' };
}
if (medRisk.includes(cat)) {
if (daysExpired <= 7) {
return { level: 'warning', icon: 'π', label: 'Controlla', tip: 'Controlla aspetto e odore prima di consumare' };
}
if (daysExpired <= 30) {
return { level: 'warning', icon: 'π', label: 'Controlla', tip: 'Scaduto da un po\', verificare bene prima dell\'uso' };
}
return { level: 'danger', icon: 'ποΈ', label: 'Buttare', tip: 'Troppo tempo dalla scadenza, meglio buttare' };
}
// LOW RISK - long shelf life items
// Pasta, conserve, condimenti, cereali, snack, bevande confezionate
// "Da consumarsi preferibilmente entro" = TMC, safe well past expiry
if (daysExpired <= 30) {
return { level: 'ok', icon: 'β
', label: 'OK', tip: 'Prodotto a lunga conservazione, ancora sicuro da consumare' };
}
if (daysExpired <= 180) {
return { level: 'warning', icon: 'π', label: 'Controlla', tip: 'Scaduto da oltre un mese, controllare integritΓ confezione' };
}
return { level: 'danger', icon: 'ποΈ', label: 'Buttare', tip: 'Scaduto da troppo tempo, meglio non rischiare' };
}
// Nice Italian labels for local categories
const CATEGORY_LABELS = {
'latticini': 'π₯ Latticini', 'carne': 'π₯© Carne', 'pesce': 'π Pesce',
'frutta': 'π Frutta', 'verdura': 'π₯¬ Verdura', 'pasta': 'π Pasta & Riso',
'pane': 'π Pane & Forno', 'surgelati': 'π§ Surgelati', 'bevande': 'π₯€ Bevande',
'condimenti': 'π§ Condimenti', 'snack': 'πͺ Snack & Dolci', 'conserve': 'π₯« Conserve',
'cereali': 'πΎ Cereali & Legumi', 'igiene': 'π§΄ Igiene', 'pulizia': 'π§Ή Pulizia',
'altro': 'π¦ Altro'
};
// Detect best unit/quantity from Open Food Facts quantity_info string
// Returns the actual package weight/volume as default (e.g. 700g β unit:'g', quantity:700)
function detectUnitAndQuantity(quantityInfo) {
if (!quantityInfo) return { unit: 'pz', quantity: 1, weightInfo: '' };
const q = quantityInfo.toLowerCase().trim();
// Match multi-pack patterns like "6 x 1l", "4 x 125g" β total weight
const multiMatch = q.match(/(\d+)\s*x\s*([\d.,]+)\s*(ml|l|g|kg|cl)/i);
if (multiMatch) {
const count = parseInt(multiMatch[1]);
let perUnitVal = parseFloat(multiMatch[2].replace(',', '.'));
let perUnitUnit = multiMatch[3].toLowerCase();
if (perUnitUnit === 'cl') { perUnitUnit = 'ml'; perUnitVal *= 10; }
const totalVal = count * perUnitVal;
return { unit: perUnitUnit, quantity: totalVal, weightInfo: quantityInfo };
}
// Match single package patterns like "500 g", "1 l", "750 ml", "1.5 kg"
const match = q.match(/([\d.,]+)\s*(kg|g|l|ml|cl)/i);
if (match) {
let unit = match[2].toLowerCase();
let val = parseFloat(match[1].replace(',', '.'));
if (unit === 'cl') { unit = 'ml'; val *= 10; }
return { unit, quantity: val, weightInfo: quantityInfo };
}
return { unit: 'pz', quantity: 1, weightInfo: quantityInfo };
}
// Estimate expiry days based on category/product type
const EXPIRY_DAYS = {
'latticini': 7, 'carne': 4, 'pesce': 3, 'frutta': 7, 'verdura': 7,
'pasta': 730, 'pane': 4, 'surgelati': 180, 'bevande': 365, 'condimenti': 365,
'snack': 180, 'conserve': 730, 'cereali': 365, 'igiene': 1095, 'pulizia': 1095, 'altro': 180
};
// More specific expiry by product name keywords
function estimateExpiryDays(product) {
const name = (product.name || '').toLowerCase();
const cat = (product.category || '').toLowerCase();
// Specific product overrides
if (/latte\s+(fresco|intero|parzial|scremato)/.test(name)) return 7;
if (/latte\s+uht|latte\s+a\s+lunga/.test(name)) return 90;
if (/yogurt/.test(name)) return 21;
if (/mozzarella|burrata|stracciatella/.test(name)) return 5;
if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 10;
if (/parmigiano|grana|pecorino|provolone/.test(name)) return 60;
if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) return 7;
if (/prosciutto\s+crudo|salame|bresaola|speck/.test(name)) return 30;
if (/uova/.test(name)) return 28;
if (/pane\s+fresco|pane\s+in\s+cassetta/.test(name)) return 5;
if (/pane\s+confezionato|pan\s+carr|pancarrè/.test(name)) return 14;
if (/insalata|rucola|spinaci\s+freschi/.test(name)) return 5;
if (/pollo|tacchino|maiale|manzo|vitello/.test(name)) return 3;
if (/salmone|tonno\s+fresco|pesce/.test(name) && !/tonno\s+in\s+scatola|tonno\s+rio/.test(name)) return 2;
if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) return 1095;
if (/surgelat|frozen|findus|4\s*salti/.test(name)) return 180;
if (/gelato/.test(name)) return 365;
if (/succo|spremuta/.test(name)) return 7;
if (/birra|vino/.test(name)) return 365;
if (/acqua/.test(name)) return 365;
if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) return 180;
if (/nutella|marmellata|miele/.test(name)) return 365;
if (/passata|pelati|pomodor/.test(name)) return 730;
if (/olio|aceto/.test(name)) return 548;
// Fallback to category
for (const [key, days] of Object.entries(EXPIRY_DAYS)) {
if (cat.includes(key)) return days;
}
return 180; // generic default
}
function formatEstimatedExpiry(days) {
if (days <= 7) return `~${days} giorni`;
if (days <= 30) return `~${Math.round(days / 7)} settimane`;
if (days <= 365) return `~${Math.round(days / 30)} mesi`;
return `~${Math.round(days / 365)} anni`;
}
function addDays(days) {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().split('T')[0];
}
// Guess location from product name keywords (fallback if no category)
function guessLocationFromName(name) {
const n = (name || '').toLowerCase();
// Frigo keywords
if (/latte|yogurt|formaggio|mozzarella|burro|panna|uova|prosciutto|salame|wurstel|ricotta|mascarpone|gorgonzola|insalata|rucola|spinaci|pollo|manzo|maiale|salmone|tonno fresco|bresaola/.test(n)) return 'frigo';
// Freezer keywords
if (/surgel|frozen|gelato|ghiaccioli|bastoncini|findus|4 salti|pizza surgel|verdure surgel|minestrone surg/.test(n)) return 'freezer';
// Dispensa keywords
if (/pasta|riso|farina|zucchero|sale|olio|aceto|biscott|cracker|grissini|caffè|tè|the |tea |tonno|pelati|passata|legumi|ceci|fagioli|lenticchie|cereali|muesli|marmell|nutella|miele|cioccolat/.test(n)) return 'dispensa';
return null; // unknown
}
function guessLocation(product) {
// 1. Category-based
if (product.category) {
const cat = product.category.toLowerCase().replace(/^en:/, '').split(',')[0].trim();
// Check our map
for (const [key, loc] of Object.entries(CATEGORY_LOCATION)) {
if (cat.includes(key)) return loc;
}
// Open Food Facts categories
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin/i.test(cat)) return 'frigo';
if (/meat|viande|carne|fish|poisson|pesce/i.test(cat)) return 'frigo';
if (/frozen|surgelΓ©|surgel/i.test(cat)) return 'freezer';
if (/fruit|vegetable|verdur|frutta/i.test(cat)) return 'frigo';
if (/beverage|drink|boisson|bevand/i.test(cat)) return 'dispensa';
if (/pasta|cereal|grain|bread|biscuit|snack|sauce|condiment|conserv|can/i.test(cat)) return 'dispensa';
}
// 2. Name-based fallback
const nameLoc = guessLocationFromName(product.name);
if (nameLoc) return nameLoc;
// 3. Default
return 'dispensa';
}
// ===== STATE =====
let currentProduct = null;
let currentInventory = [];
let currentLocation = '';
let scannerStream = null;
let quaggaRunning = false;
let aiStream = null;
// ===== API HELPER =====
async function api(action, params = {}, method = 'GET', body = null) {
let url = `${API_BASE}?action=${action}`;
if (method === 'GET') {
Object.entries(params).forEach(([k, v]) => {
url += `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
});
}
const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
return res.json();
}
// ===== PAGE NAVIGATION =====
function showPage(pageId, param = null) {
// Hide all pages
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
// Show target page
const page = document.getElementById(`page-${pageId}`);
if (page) page.classList.add('active');
// Update nav
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-btn[data-page="${pageId}"]`);
if (navBtn) navBtn.classList.add('active');
// Page-specific init
switch(pageId) {
case 'dashboard': loadDashboard(); break;
case 'inventory':
if (param !== null) {
currentLocation = param;
filterLocation(param);
}
loadInventory();
break;
case 'scan': initScanner(); clearQuickNameResults(); break;
case 'products': loadAllProducts(); break;
case 'shopping': loadShoppingList(); break;
case 'ai': initAICamera(); break;
}
// Stop scanner when leaving scan page
if (pageId !== 'scan' && pageId !== 'ai') {
stopScanner();
}
// Scroll to top
window.scrollTo(0, 0);
}
// ===== DASHBOARD =====
async function loadDashboard() {
try {
const [summaryData, statsData, invData] = await Promise.all([
api('inventory_summary'),
api('stats'),
api('inventory_list')
]);
// Update stat cards
const summary = summaryData.summary || [];
let total = 0;
['dispensa', 'frigo', 'freezer'].forEach(loc => {
const s = summary.find(x => x.location === loc);
const count = s ? s.product_count : 0;
document.getElementById(`stat-${loc}`).textContent = count;
total += count;
});
// Add non-standard locations
summary.forEach(s => {
if (!['dispensa', 'frigo', 'freezer'].includes(s.location)) {
total += s.product_count;
}
});
document.getElementById('stat-total').textContent = total || summary.reduce((a, s) => a + s.product_count, 0);
// Expiring items
const expiringSection = document.getElementById('alert-expiring');
const expiringList = document.getElementById('expiring-list');
if (statsData.expiring_soon && statsData.expiring_soon.length > 0) {
expiringSection.style.display = 'block';
expiringList.innerHTML = statsData.expiring_soon.map(item => {
const days = daysUntilExpiry(item.expiry_date);
let badgeText, badgeClass;
if (days === 0) { badgeText = 'OGGI'; badgeClass = 'today'; }
else if (days === 1) { badgeText = 'Domani'; badgeClass = 'expiring'; }
else if (days <= 7) { badgeText = `${days} giorni`; badgeClass = 'expiring'; }
else if (days <= 30) { badgeText = `${days}g`; badgeClass = 'expiring-soon'; }
else { const m = Math.round(days/30); badgeText = m <= 1 ? `${days}g` : `~${m} mesi`; badgeClass = 'expiring-later'; }
return `
${escapeHtml(item.name)}
${item.brand ? `${escapeHtml(item.brand)} ` : ''}
${badgeText}
`;
}).join('');
} else {
expiringSection.style.display = 'none';
}
// Expired items
const expiredSection = document.getElementById('alert-expired');
const expiredList = document.getElementById('expired-list');
if (statsData.expired && statsData.expired.length > 0) {
expiredSection.style.display = 'block';
expiredList.innerHTML = statsData.expired.map(item => {
const days = Math.abs(daysUntilExpiry(item.expiry_date));
let daysText;
if (days === 0) daysText = 'Oggi';
else if (days === 1) daysText = 'Da ieri';
else daysText = `Da ${days}g`;
const safety = getExpiredSafety(item, days);
return `
${escapeHtml(item.name)}
${item.brand ? `${escapeHtml(item.brand)} ` : ''}
${daysText}
${safety.icon} ${safety.label}
`;
}).join('');
} else {
expiredSection.style.display = 'none';
}
// Full inventory grouped by location, then by category within each location
const allItems = invData.inventory || [];
const grouped = { dispensa: [], frigo: [], freezer: [], altro: [] };
allItems.forEach(item => {
const loc = grouped[item.location] !== undefined ? item.location : 'altro';
grouped[loc].push(item);
});
for (const [loc, items] of Object.entries(grouped)) {
const section = document.getElementById(`dash-section-${loc}`);
const container = document.getElementById(`dash-inv-${loc}`);
if (items.length === 0) {
section.style.display = 'none';
} else {
section.style.display = 'block';
container.innerHTML = renderGroupedByCategory(items, true);
}
}
} catch (err) {
console.error('Dashboard load error:', err);
}
}
// Group items by local category and render with category headers
function renderGroupedByCategory(items, compact = false) {
const catGroups = {};
items.forEach(item => {
const localCat = mapToLocalCategory(item.category, item.name);
if (!catGroups[localCat]) catGroups[localCat] = [];
catGroups[localCat].push(item);
});
// Sort categories: use CATEGORY_ICONS key order
const catOrder = Object.keys(CATEGORY_ICONS);
const sortedCats = Object.keys(catGroups).sort((a, b) => {
const ia = catOrder.indexOf(a);
const ib = catOrder.indexOf(b);
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
});
let html = '';
for (const cat of sortedCats) {
const catItems = catGroups[cat];
const label = CATEGORY_LABELS[cat] || 'π¦ Altro';
html += ``;
html += catItems.map(item => compact ? renderDashItem(item) : renderInventoryItem(item)).join('');
}
return html;
}
function renderDashItem(item) {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || 'π¦';
const days = daysUntilExpiry(item.expiry_date);
const isExpired = days < 0;
const isExpiring = !isExpired && days <= 7;
const qtyDisplay = formatQuantity(item.quantity, item.unit);
let expiryLabel = '';
if (item.expiry_date) {
if (days < 0) expiryLabel = `β οΈ Scaduto da ${Math.abs(days)}g`;
else if (days === 0) expiryLabel = 'β οΈ Scade oggi!';
else if (days === 1) expiryLabel = 'β° Scade domani';
else if (days <= 7) expiryLabel = `β° ${days} giorni`;
else expiryLabel = formatDate(item.expiry_date);
}
return `
${item.image_url ? `
` : catIcon}
${escapeHtml(item.name)}
${item.brand ? `
${escapeHtml(item.brand)}
` : ''}
${qtyDisplay}
${expiryLabel ? `${expiryLabel} ` : ''}
`;
}
function dashItemTap(inventoryId, productId) {
// Load full inventory so modal works
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
});
}
function showAlertItemDetail(inventoryId, productId) {
// Load full inventory so modal works (same pattern as dashItemTap)
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
});
}
function formatQuantity(qty, unit) {
if (!qty && qty !== 0) return '';
const n = parseFloat(qty);
const unitLabels = { 'pz': 'pz', 'kg': 'kg', 'g': 'g', 'l': 'L', 'ml': 'ml', 'conf': 'conf' };
const label = unitLabels[unit] || unit || 'pz';
// Format nicely
if (n === Math.floor(n)) return `${Math.floor(n)} ${label}`;
return `${n.toFixed(1)} ${label}`;
}
// ===== INVENTORY =====
async function loadInventory() {
try {
const data = await api('inventory_list', currentLocation ? { location: currentLocation } : {});
currentInventory = data.inventory || [];
renderInventory(currentInventory);
} catch (err) {
console.error('Inventory load error:', err);
}
}
function renderInventoryItem(item) {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || 'π¦';
const locInfo = LOCATIONS[item.location] || { icon: 'π¦', label: item.location };
const days = daysUntilExpiry(item.expiry_date);
const isExpired = days < 0;
const isExpiring = !isExpired && days <= 7;
const qtyDisplay = formatQuantity(item.quantity, item.unit);
let expiryBadge = '';
if (item.expiry_date) {
let expiryText;
if (isExpired) expiryText = `β οΈ Scaduto da ${Math.abs(days)}g`;
else if (days === 0) expiryText = 'β οΈ Scade oggi!';
else if (days === 1) expiryText = 'β° Domani';
else if (days <= 7) expiryText = `β° ${days} giorni`;
else expiryText = formatDate(item.expiry_date);
expiryBadge = `${expiryText} `;
}
return `
${item.image_url ? `
` : catIcon}
${escapeHtml(item.name)}
${item.brand ? `
${escapeHtml(item.brand)}
` : ''}
${locInfo.icon} ${locInfo.label}
${qtyDisplay}
${expiryBadge}
`;
}
function renderInventory(items) {
const container = document.getElementById('inventory-list');
if (items.length === 0) {
container.innerHTML = 'π
Nessun prodotto qui. Scansiona un prodotto per aggiungerlo!
';
return;
}
container.innerHTML = renderGroupedByCategory(items, false);
}
function filterLocation(loc) {
currentLocation = loc;
document.querySelectorAll('.location-tabs .tab').forEach(t => {
t.classList.toggle('active', t.dataset.loc === loc);
});
loadInventory();
}
function filterInventory() {
const q = document.getElementById('inventory-search').value.toLowerCase();
if (!q) {
renderInventory(currentInventory);
return;
}
const filtered = currentInventory.filter(i =>
i.name.toLowerCase().includes(q) ||
(i.brand && i.brand.toLowerCase().includes(q)) ||
(i.barcode && i.barcode.includes(q))
);
renderInventory(filtered);
}
// ===== ITEM DETAIL MODAL =====
function showItemDetail(inventoryId, productId) {
const item = currentInventory.find(i => i.id === inventoryId);
if (!item) return;
const locInfo = LOCATIONS[item.location] || { icon: 'π¦', label: item.location };
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || 'π¦';
document.getElementById('modal-content').innerHTML = `
${item.image_url ?
`
` :
`
${catIcon} `
}
${escapeHtml(item.name)}
${item.brand ? escapeHtml(item.brand) : ''}
π Posizione
${locInfo.icon} ${locInfo.label}
π¦ QuantitΓ
${item.quantity} ${item.unit}
${item.expiry_date ? `
π
Scadenza
${formatDate(item.expiry_date)}
` : ''}
${item.barcode ? `
π Barcode
${item.barcode}
` : ''}
π
Aggiunto
${formatDateTime(item.added_at)}
π€ Usa
βοΈ Modifica
ποΈ
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
}
async function quickUse(productId, location) {
closeModal();
showLoading(true);
try {
currentProduct = { id: productId };
// Get product info
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
// Extract weight_info from notes if available
if (!currentProduct.weight_info && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^Β·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
}
document.getElementById('use-location').value = location;
// Mark active location button
document.querySelectorAll('#page-use .loc-btn').forEach(b => b.classList.remove('active'));
const locBtns = document.querySelectorAll('#page-use .loc-btn');
locBtns.forEach(b => {
if (b.textContent.toLowerCase().includes(location)) b.classList.add('active');
});
renderUsePreview();
loadUseInventoryInfo();
showLoading(false);
showPage('use');
} catch (err) {
showLoading(false);
console.error('quickUse error:', err);
showToast('Errore nel caricamento del prodotto', 'error');
}
}
async function deleteInventoryItem(id) {
if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast('Prodotto rimosso', 'success');
loadInventory();
}
}
function editInventoryItem(id) {
const item = currentInventory.find(i => i.id === id);
if (!item) {
closeModal();
showToast('Prodotto non trovato', 'error');
return;
}
// Rebuild modal content for editing (don't close and reopen - just replace content)
document.getElementById('modal-content').innerHTML = `
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
async function submitEditInventory(e, id) {
e.preventDefault();
const qty = parseFloat(document.getElementById('edit-qty').value);
const loc = document.getElementById('edit-loc').value;
const expiry = document.getElementById('edit-expiry').value || null;
await api('inventory_update', {}, 'POST', { id, quantity: qty, location: loc, expiry_date: expiry });
closeModal();
showToast('Aggiornato!', 'success');
loadInventory();
}
// ===== BARCODE SCANNER =====
async function initScanner() {
const video = document.getElementById('scanner-video');
const viewport = document.getElementById('scanner-viewport');
try {
// Stop any existing stream
stopScanner();
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
scannerStream = stream;
video.srcObject = stream;
await video.play();
// Start Quagga for barcode detection
startQuagga(video);
} catch (err) {
console.error('Camera error:', err);
document.getElementById('scan-result').style.display = 'block';
document.getElementById('scan-result').innerHTML = `
β οΈ Impossibile accedere alla fotocamera.
Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.
Puoi inserire il barcode manualmente o usare l'identificazione AI.
`;
}
}
function startQuagga(videoEl) {
if (quaggaRunning) return;
const canvas = document.getElementById('scanner-canvas');
const ctx = canvas.getContext('2d');
let scanning = true;
quaggaRunning = true;
let lastDetected = '';
let detectCount = 0;
function scanFrame() {
if (!scanning || !scannerStream) return;
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
try {
Quagga.decodeSingle({
src: canvas.toDataURL('image/jpeg', 0.8),
numOfWorkers: 0,
inputStream: { size: 800 },
decoder: {
readers: [
'ean_reader',
'ean_8_reader',
'code_128_reader',
'code_39_reader',
'upc_reader',
'upc_e_reader'
]
},
locate: true
}, function(result) {
if (result && result.codeResult) {
const code = result.codeResult.code;
if (code === lastDetected) {
detectCount++;
} else {
lastDetected = code;
detectCount = 1;
}
// Require 2 consecutive reads for reliability
if (detectCount >= 2) {
scanning = false;
quaggaRunning = false;
onBarcodeDetected(code);
return;
}
}
if (scanning) {
setTimeout(scanFrame, 300);
}
});
} catch (e) {
if (scanning) setTimeout(scanFrame, 500);
}
}
// Start scanning after a small delay
setTimeout(scanFrame, 500);
}
function stopScanner() {
quaggaRunning = false;
if (scannerStream) {
scannerStream.getTracks().forEach(t => t.stop());
scannerStream = null;
}
const video = document.getElementById('scanner-video');
if (video) video.srcObject = null;
// Also stop AI camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
aiStream = null;
}
const aiVideo = document.getElementById('ai-video');
if (aiVideo) aiVideo.srcObject = null;
}
async function onBarcodeDetected(barcode) {
showLoading(true);
// Vibrate if available
if (navigator.vibrate) navigator.vibrate(100);
try {
// First check local DB
const localResult = await api('search_barcode', { barcode });
if (localResult.found) {
currentProduct = localResult.product;
// If product was saved with 'pz' but has weight info in notes, fix defaults
if (currentProduct.unit === 'pz' && currentProduct.default_quantity <= 1 && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^Β·]+)/);
if (pesoMatch) {
const weightStr = pesoMatch[1].trim();
const detected = detectUnitAndQuantity(weightStr);
if (detected.unit !== 'pz') {
currentProduct.unit = detected.unit;
currentProduct.default_quantity = detected.quantity;
currentProduct.weight_info = weightStr;
// Update product in DB for future scans
api('product_save', {}, 'POST', {
id: currentProduct.id,
barcode: currentProduct.barcode,
name: currentProduct.name,
brand: currentProduct.brand || '',
category: currentProduct.category || '',
image_url: currentProduct.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: currentProduct.notes,
});
}
}
}
// Extract weight_info from notes if available (stored as "Peso: 500 g Β· ...")
if (!currentProduct.weight_info && currentProduct.notes) {
const pesoMatch = currentProduct.notes.match(/Peso:\s*([^Β·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
showLoading(false);
stopScanner();
showProductAction();
return;
}
// Lookup in external DB
const lookupResult = await api('lookup_barcode', { barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
// Detect unit and quantity from quantity_info
const detected = detectUnitAndQuantity(p.quantity_info);
// Build rich notes with all available info
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
if (p.labels) notesParts.push(`Etichette: ${p.labels}`);
// Save to local DB
const saveResult = await api('product_save', {}, 'POST', {
barcode: barcode,
name: p.name || 'Prodotto sconosciuto',
brand: p.brand || '',
category: p.category || '',
image_url: p.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: notesParts.join(' Β· '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: barcode,
name: p.name || 'Prodotto sconosciuto',
brand: p.brand || '',
category: p.category || '',
image_url: p.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
weight_info: p.quantity_info || '',
nutriscore: p.nutriscore || '',
ingredients: p.ingredients || '',
allergens: p.allergens || '',
conservation: p.conservation || '',
origin: p.origin || '',
nova_group: p.nova_group || '',
ecoscore: p.ecoscore || '',
labels: p.labels || '',
stores: p.stores || '',
};
showLoading(false);
stopScanner();
showProductAction();
return;
}
}
// Not found - ask user to add manually
showLoading(false);
stopScanner();
showToast('Prodotto non trovato. Inseriscilo manualmente.', 'error');
startManualEntry(barcode);
} catch (err) {
showLoading(false);
console.error('Barcode lookup error:', err);
showToast('Errore nella ricerca. Riprova.', 'error');
}
}
function submitManualBarcode() {
const input = document.getElementById('manual-barcode-input');
const barcode = (input.value || '').trim();
if (!barcode) {
showToast('Inserisci un codice a barre', 'error');
input.focus();
return;
}
if (!/^\d{4,14}$/.test(barcode)) {
showToast('Il codice a barre deve contenere solo numeri (4-14 cifre)', 'error');
input.focus();
return;
}
stopScanner();
onBarcodeDetected(barcode);
}
// ===== QUICK NAME ENTRY (for loose/unpackaged products) =====
async function submitQuickName() {
const input = document.getElementById('quick-product-name');
const name = (input.value || '').trim();
if (!name || name.length < 2) {
showToast('Scrivi almeno 2 caratteri', 'error');
input.focus();
return;
}
stopScanner();
showLoading(true);
try {
// Search local products DB
const localData = await api('products_search', { q: name });
const localProducts = (localData.products || []).slice(0, 5);
showLoading(false);
if (localProducts.length > 0) {
// Show results to pick from + option to create new
showQuickNameResults(name, localProducts);
} else {
// No local results β create new product directly
await createQuickProduct(name);
}
} catch (err) {
showLoading(false);
console.error('Quick name search error:', err);
showToast('Errore nella ricerca', 'error');
}
}
function showQuickNameResults(searchName, products) {
const container = document.querySelector('.quick-name-entry');
// Remove any previous results
const oldResults = container.querySelector('.quick-name-results');
if (oldResults) oldResults.remove();
const resultsDiv = document.createElement('div');
resultsDiv.className = 'quick-name-results';
// Existing products
products.forEach(p => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(p.category, p.name)] || 'π¦';
const item = document.createElement('div');
item.className = 'quick-name-result-item';
item.innerHTML = `
${catIcon}
${escapeHtml(p.name)}
${p.brand ? escapeHtml(p.brand) + ' Β· ' : ''}${p.barcode ? 'π ' + p.barcode : 'Senza barcode'}
`;
item.onclick = () => selectQuickProduct(p);
resultsDiv.appendChild(item);
});
// "Create new" button
const newItem = document.createElement('div');
newItem.className = 'quick-name-result-item qnr-new';
newItem.innerHTML = `
β
Crea "${escapeHtml(searchName)}"
Nuovo prodotto senza barcode
`;
newItem.onclick = () => createQuickProduct(searchName);
resultsDiv.appendChild(newItem);
container.appendChild(resultsDiv);
}
function selectQuickProduct(product) {
currentProduct = {
id: product.id,
barcode: product.barcode || '',
name: product.name,
brand: product.brand || '',
category: product.category || '',
image_url: product.image_url || '',
unit: product.unit || 'pz',
default_quantity: product.default_quantity || 1,
};
// Extract weight_info from notes if available
if (product.notes) {
const pesoMatch = product.notes.match(/Peso:\s*([^Β·]+)/);
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
}
clearQuickNameResults();
showProductAction();
}
async function createQuickProduct(name) {
showLoading(true);
// Auto-detect category from name
const category = guessCategoryFromName(name);
try {
const result = await api('product_save', {}, 'POST', {
name: name,
brand: '',
category: category,
unit: 'pz',
default_quantity: 1,
});
if (result.success || result.id) {
currentProduct = {
id: result.id,
name: name,
brand: '',
category: category,
unit: 'pz',
default_quantity: 1,
};
showLoading(false);
clearQuickNameResults();
showToast('Prodotto creato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
console.error('Quick product creation error:', err);
showToast('Errore di connessione', 'error');
}
}
function clearQuickNameResults() {
const container = document.querySelector('.quick-name-entry');
if (container) {
const results = container.querySelector('.quick-name-results');
if (results) results.remove();
}
const input = document.getElementById('quick-product-name');
if (input) input.value = '';
}
function startManualEntry(barcode = '') {
stopScanner();
// Reset form
document.getElementById('pf-id').value = '';
document.getElementById('pf-name').value = '';
document.getElementById('pf-brand').value = '';
document.getElementById('pf-category').value = '';
document.getElementById('pf-unit').value = 'pz';
document.getElementById('pf-defqty').value = '1';
document.getElementById('pf-notes').value = '';
document.getElementById('pf-barcode').value = barcode || '';
document.getElementById('pf-image').value = '';
document.getElementById('pf-image-preview').style.display = 'none';
document.getElementById('product-form-title').textContent = 'Nuovo Prodotto';
// Reset manual-edit tracking flags
document.getElementById('pf-category').dataset.manuallySet = 'false';
document.getElementById('pf-defqty').dataset.manuallySet = 'false';
// Track if user manually changes the quantity field
const qtyInput = document.getElementById('pf-defqty');
qtyInput.removeEventListener('input', markQtyManuallySet);
qtyInput.addEventListener('input', markQtyManuallySet);
// Auto-detect name β category when typing
const nameInput = document.getElementById('pf-name');
nameInput.removeEventListener('input', autoDetectCategory);
nameInput.addEventListener('input', autoDetectCategory);
showPage('product-form');
}
function markQtyManuallySet() {
document.getElementById('pf-defqty').dataset.manuallySet = 'true';
}
function autoDetectCategory() {
const name = document.getElementById('pf-name').value.toLowerCase();
if (name.length < 3) return;
const catSelect = document.getElementById('pf-category');
// Don't override if user already manually selected something
if (catSelect.dataset.manuallySet === 'true') return;
// Keywords β category mapping
const keyword2cat = {
'latte': 'latticini', 'yogurt': 'latticini', 'formaggio': 'latticini', 'mozzarella': 'latticini',
'burro': 'latticini', 'panna': 'latticini', 'ricotta': 'latticini', 'mascarpone': 'latticini',
'gorgonzola': 'latticini', 'parmigiano': 'latticini', 'grana': 'latticini', 'burrata': 'latticini',
'stracchino': 'latticini', 'uova': 'latticini',
'pollo': 'carne', 'manzo': 'carne', 'maiale': 'carne', 'vitello': 'carne', 'tacchino': 'carne',
'prosciutto': 'carne', 'salame': 'carne', 'bresaola': 'carne', 'mortadella': 'carne',
'wurstel': 'carne', 'macinato': 'carne', 'speck': 'carne',
'salmone': 'pesce', 'tonno': 'pesce', 'sgombro': 'pesce', 'pesce': 'pesce', 'merluzzo': 'pesce',
'mela': 'frutta', 'mele': 'frutta', 'banana': 'frutta', 'arancia': 'frutta', 'pera': 'frutta',
'fragola': 'frutta', 'uva': 'frutta', 'kiwi': 'frutta', 'limone': 'frutta',
'insalata': 'verdura', 'pomodor': 'verdura', 'zucchin': 'verdura', 'patat': 'verdura',
'cipoll': 'verdura', 'carota': 'verdura', 'spinaci': 'verdura', 'rucola': 'verdura',
'peperoni': 'verdura', 'melanzane': 'verdura', 'broccoli': 'verdura',
'pasta': 'pasta', 'spaghetti': 'pasta', 'penne': 'pasta', 'fusilli': 'pasta', 'riso': 'pasta',
'farina': 'pasta', 'rigatoni': 'pasta', 'farfalle': 'pasta',
'pane': 'pane', 'fette biscottate': 'pane', 'pancarrè': 'pane', 'pan carrè': 'pane',
'grissini': 'pane', 'crackers': 'pane', 'cracker': 'pane',
'surgelat': 'surgelati', 'findus': 'surgelati', 'gelato': 'surgelati',
'acqua': 'bevande', 'succo': 'bevande', 'birra': 'bevande', 'vino': 'bevande',
'coca cola': 'bevande', 'aranciata': 'bevande', 'tè': 'bevande', 'caffè': 'bevande',
'olio': 'condimenti', 'aceto': 'condimenti', 'sale': 'condimenti', 'pepe': 'condimenti',
'maionese': 'condimenti', 'ketchup': 'condimenti', 'senape': 'condimenti', 'zucchero': 'condimenti',
'biscott': 'snack', 'cioccolat': 'snack', 'nutella': 'snack', 'merendine': 'snack',
'patatine': 'snack', 'caramelle': 'snack',
'pelati': 'conserve', 'passata': 'conserve', 'legumi': 'conserve', 'ceci': 'conserve',
'fagioli': 'conserve', 'lenticchie': 'conserve', 'marmellata': 'conserve', 'miele': 'conserve',
'cereali': 'cereali', 'muesli': 'cereali', 'fiocchi': 'cereali',
};
for (const [keyword, cat] of Object.entries(keyword2cat)) {
if (name.includes(keyword)) {
catSelect.value = cat;
onCategoryChange(true);
return;
}
}
}
function onCategoryChange(fromAutoDetect = false) {
const cat = document.getElementById('pf-category').value;
const unitSelect = document.getElementById('pf-unit');
const qtyInput = document.getElementById('pf-defqty');
// If user manually changed category via dropdown, don't auto-fill qty/unit
if (!fromAutoDetect) {
// Mark qty as "set" so future auto-detects won't overwrite either
qtyInput.dataset.manuallySet = 'true';
return;
}
// Auto-detect from name: suggest default unit/qty based on category
// BUT only if user hasn't manually changed the quantity field
const catDefaults = {
'latticini': { unit: 'pz', qty: 1 },
'carne': { unit: 'g', qty: 500 },
'pesce': { unit: 'g', qty: 300 },
'frutta': { unit: 'kg', qty: 1 },
'verdura': { unit: 'kg', qty: 0.5 },
'pasta': { unit: 'g', qty: 500 },
'pane': { unit: 'pz', qty: 1 },
'surgelati': { unit: 'g', qty: 450 },
'bevande': { unit: 'l', qty: 1 },
'condimenti': { unit: 'pz', qty: 1 },
'snack': { unit: 'g', qty: 250 },
'conserve': { unit: 'g', qty: 400 },
'cereali': { unit: 'g', qty: 500 },
'igiene': { unit: 'pz', qty: 1 },
'pulizia': { unit: 'pz', qty: 1 },
};
if (catDefaults[cat]) {
// Only auto-fill unit/qty if user hasn't manually touched them
if (qtyInput.dataset.manuallySet !== 'true') {
unitSelect.value = catDefaults[cat].unit;
qtyInput.value = catDefaults[cat].qty;
}
}
}
async function submitProduct(e) {
e.preventDefault();
showLoading(true);
const productData = {
id: document.getElementById('pf-id').value || null,
name: document.getElementById('pf-name').value,
brand: document.getElementById('pf-brand').value,
category: document.getElementById('pf-category').value,
unit: document.getElementById('pf-unit').value,
default_quantity: parseFloat(document.getElementById('pf-defqty').value) || 1,
notes: document.getElementById('pf-notes').value,
barcode: document.getElementById('pf-barcode').value || null,
image_url: document.getElementById('pf-image').value || '',
};
try {
const result = await api('product_save', {}, 'POST', productData);
if (result.success) {
currentProduct = { ...productData, id: result.id };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== PRODUCT ACTION (IN/OUT) =====
function showProductAction() {
if (!currentProduct) return;
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || 'π¦';
const nutriscoreColors = { a: '#1e8f4e', b: '#60ac0e', c: '#eeae0e', d: '#ff6f1e', e: '#e63e11' };
let detailsHtml = '';
// Weight / quantity info
if (currentProduct.weight_info) {
detailsHtml += `βοΈ ${escapeHtml(currentProduct.weight_info)}
`;
}
// Nutriscore badge
if (currentProduct.nutriscore) {
const ns = currentProduct.nutriscore.toLowerCase();
const nsColor = nutriscoreColors[ns] || '#999';
detailsHtml += `Nutri-Score ${ns.toUpperCase()}
`;
}
// NOVA group
if (currentProduct.nova_group) {
const novaLabels = { '1': 'Non trasformato', '2': 'Ingrediente culinario', '3': 'Trasformato', '4': 'Ultra-trasformato' };
detailsHtml += `π NOVA ${currentProduct.nova_group}${novaLabels[currentProduct.nova_group] ? ' - ' + novaLabels[currentProduct.nova_group] : ''}
`;
}
// Ecoscore
if (currentProduct.ecoscore) {
const es = currentProduct.ecoscore.toLowerCase();
const esColor = nutriscoreColors[es] || '#999';
detailsHtml += `π Eco-Score ${es.toUpperCase()}
`;
}
// Origin
if (currentProduct.origin) {
detailsHtml += `π ${escapeHtml(currentProduct.origin)}
`;
}
// Labels (bio, DOP, etc.)
if (currentProduct.labels) {
detailsHtml += `π·οΈ ${escapeHtml(currentProduct.labels)}
`;
}
// Allergens
let allergensHtml = '';
if (currentProduct.allergens) {
allergensHtml = `β οΈ Allergeni: ${escapeHtml(currentProduct.allergens)}
`;
}
// Ingredients (collapsible)
let ingredientsHtml = '';
if (currentProduct.ingredients) {
const ingredShort = currentProduct.ingredients.length > 120
? currentProduct.ingredients.substring(0, 120) + '...'
: currentProduct.ingredients;
ingredientsHtml = `
π Ingredienti
${escapeHtml(currentProduct.ingredients)}
`;
}
// Conservation
let conservationHtml = '';
if (currentProduct.conservation) {
conservationHtml = `π§ ${escapeHtml(currentProduct.conservation)}
`;
}
document.getElementById('action-product-preview').innerHTML = `
${currentProduct.image_url ?
` ` :
`${catIcon} `
}
${escapeHtml(currentProduct.name)}
${currentProduct.brand ? `${escapeHtml(currentProduct.brand)} ` : ''}
${currentProduct.barcode ? `
π ${currentProduct.barcode}
` : ''}
`;
// Check if product needs editing (unknown name, missing info)
const isUnknown = !currentProduct.name ||
/sconosciuto|unknown|^$/i.test(currentProduct.name.trim()) ||
currentProduct.name.trim().length < 2;
const needsEdit = isUnknown || !currentProduct.brand;
// Edit product info section
let editInfoEl = document.getElementById('action-edit-info');
if (!editInfoEl) {
editInfoEl = document.createElement('div');
editInfoEl.id = 'action-edit-info';
const preview = document.getElementById('action-product-preview');
preview.parentElement.insertBefore(editInfoEl, preview.nextSibling);
}
if (needsEdit) {
const categoryOptions = Object.entries(CATEGORY_LABELS).map(([key, label]) =>
`${label} `
).join('');
editInfoEl.innerHTML = `
${isUnknown ? 'β οΈ Prodotto non riconosciuto' : 'βοΈ Completa le informazioni'}
${isUnknown ? 'Inserisci il nome e le informazioni del prodotto' : 'Puoi modificare o completare le info mancanti'}
`;
editInfoEl.style.display = 'block';
// Focus name field if unknown
if (isUnknown) {
setTimeout(() => document.getElementById('edit-action-name')?.focus(), 100);
}
} else {
editInfoEl.style.display = 'none';
editInfoEl.innerHTML = '';
}
// Show extra product info section below preview
let extraInfoEl = document.getElementById('action-product-details');
if (!extraInfoEl) {
const container = document.getElementById('action-product-preview').parentElement;
extraInfoEl = document.createElement('div');
extraInfoEl.id = 'action-product-details';
// Insert after preview, before action buttons
const actionBtns = document.querySelector('#page-action .action-buttons');
actionBtns.parentElement.insertBefore(extraInfoEl, actionBtns);
}
if (detailsHtml || allergensHtml || ingredientsHtml || conservationHtml) {
extraInfoEl.innerHTML = `
${detailsHtml ? `
${detailsHtml}
` : ''}
${allergensHtml}
${ingredientsHtml}
${conservationHtml}
`;
extraInfoEl.style.display = 'block';
} else {
extraInfoEl.style.display = 'none';
extraInfoEl.innerHTML = '';
}
showPage('action');
}
async function saveEditedProductInfo() {
const name = (document.getElementById('edit-action-name')?.value || '').trim();
if (!name) {
showToast('Inserisci il nome del prodotto', 'error');
document.getElementById('edit-action-name')?.focus();
return;
}
const brand = (document.getElementById('edit-action-brand')?.value || '').trim();
const category = document.getElementById('edit-action-category')?.value || '';
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
id: currentProduct.id,
barcode: currentProduct.barcode || null,
name: name,
brand: brand,
category: category || currentProduct.category || '',
image_url: currentProduct.image_url || '',
unit: currentProduct.unit || 'pz',
default_quantity: currentProduct.default_quantity || 1,
notes: currentProduct.notes || '',
});
showLoading(false);
if (result.success) {
// Update current product in memory
currentProduct.name = name;
currentProduct.brand = brand;
if (category) currentProduct.category = category;
showToast('β
Prodotto aggiornato!', 'success');
// Refresh the action page with updated data
showProductAction();
} else {
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== ADD TO INVENTORY =====
function showAddForm() {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct.category, currentProduct.name)] || 'π¦';
document.getElementById('add-product-preview').innerHTML = `
${currentProduct.image_url ?
` ` :
`${catIcon} `
}
${escapeHtml(currentProduct.name)}
${currentProduct.brand ? escapeHtml(currentProduct.brand) : ''}
${currentProduct.weight_info ? `
${escapeHtml(currentProduct.weight_info)}
` : ''}
`;
// Set unit selector
const unit = currentProduct.unit || 'pz';
const unitSelect = document.getElementById('add-unit');
unitSelect.value = unit;
document.getElementById('add-quantity').value = currentProduct.default_quantity || 1;
document.getElementById('add-quantity').dataset.manuallySet = 'false';
// Track manual edits to quantity in add form
const addQtyInput = document.getElementById('add-quantity');
addQtyInput.removeEventListener('input', markAddQtyManuallySet);
addQtyInput.addEventListener('input', markAddQtyManuallySet);
// Show weight info if product has it
const weightInfoEl = document.getElementById('add-weight-info');
if (currentProduct.weight_info) {
weightInfoEl.textContent = `π¦ Confezione: ${currentProduct.weight_info}`;
weightInfoEl.style.display = 'block';
} else {
weightInfoEl.style.display = 'none';
}
// Set qty step based on selected unit
updateAddQtyStep();
// Auto-detect location
const autoLoc = guessLocation(currentProduct);
document.getElementById('add-location').value = autoLoc;
// Highlight correct location button
document.querySelectorAll('#page-add .loc-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#page-add .loc-btn').forEach(b => {
const btnText = b.textContent.toLowerCase();
if (btnText.includes(autoLoc)) b.classList.add('active');
});
// Show the purchase-type selector
const expirySection = document.getElementById('add-expiry-section');
const estimatedDays = estimateExpiryDays(currentProduct);
const estimatedDate = addDays(estimatedDays);
const estimateLabel = formatEstimatedExpiry(estimatedDays);
expirySection.innerHTML = `
π Questo prodotto Γ¨...
π Appena comprato
π¦ Ce l'avevo giΓ
Scadenza stimata: ${estimateLabel}
${formatDate(estimatedDate)}
π·
π Puoi modificare la data o scansionarla con la fotocamera
`;
showPage('add');
}
function onAddUnitChange() {
updateAddQtyStep();
// If switching units, suggest a sensible quantity
// BUT only if the user hasn't manually changed the quantity in this form
const unit = document.getElementById('add-unit').value;
const qtyInput = document.getElementById('add-quantity');
if (qtyInput.dataset.manuallySet === 'true') return; // User already edited qty, don't overwrite
const currentQty = parseFloat(qtyInput.value) || 1;
// Convert between related units if logical
if (unit === 'g' && currentQty <= 10) qtyInput.value = currentProduct.weight_info ? parseFloat(currentProduct.weight_info) || 250 : 250;
if (unit === 'kg' && currentQty > 100) qtyInput.value = (currentQty / 1000).toFixed(1);
if (unit === 'ml' && currentQty <= 10) qtyInput.value = 500;
if (unit === 'l' && currentQty > 100) qtyInput.value = (currentQty / 1000).toFixed(1);
if (unit === 'pz' && currentQty > 100) qtyInput.value = 1;
if (unit === 'conf' && currentQty > 10) qtyInput.value = 1;
}
function updateAddQtyStep() {
const qtyInput = document.getElementById('add-quantity');
const unit = document.getElementById('add-unit').value;
qtyInput.step = 'any';
if (unit === 'g' || unit === 'ml') {
qtyInput.min = '1';
} else if (unit === 'kg' || unit === 'l') {
qtyInput.min = '0.1';
} else {
qtyInput.min = '1';
}
}
function markAddQtyManuallySet() {
document.getElementById('add-quantity').dataset.manuallySet = 'true';
}
function adjustAddQty(delta) {
const qtyInput = document.getElementById('add-quantity');
qtyInput.dataset.manuallySet = 'true'; // +/- buttons count as manual edit
const unit = document.getElementById('add-unit').value;
let val = parseFloat(qtyInput.value) || 0;
let step;
if (unit === 'kg' || unit === 'l') {
step = val < 1 ? 0.1 : 0.5;
} else if (unit === 'g' || unit === 'ml') {
step = val < 50 ? 1 : (val < 500 ? 10 : 50);
} else {
step = 1;
}
val = Math.max(parseFloat(qtyInput.min) || 0.1, val + delta * step);
// Round nicely
if (step >= 1) val = Math.round(val);
else val = Math.round(val * 10) / 10;
qtyInput.value = val;
}
function selectPurchaseType(btn, type, estimatedDate, estimateLabel) {
btn.parentElement.querySelectorAll('.purchase-type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const detailDiv = document.getElementById('expiry-detail');
// Save current quantity before switching, so we can preserve it
const currentQty = document.getElementById('add-quantity').value;
if (type === 'new') {
detailDiv.innerHTML = `
Scadenza stimata: ${estimateLabel}
${formatDate(estimatedDate)}
π·
π Puoi modificare la data o scansionarla con la fotocamera
`;
// Restore quantity - switching purchase type should NOT change it
document.getElementById('add-quantity').value = currentQty;
} else {
detailDiv.innerHTML = `
`;
// DON'T auto-set remaining percentage - keep the quantity the user already entered
}
}
function setRemainingPct(pct) {
document.querySelectorAll('.remaining-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
const baseQty = currentProduct.default_quantity || 1;
const unit = currentProduct.unit || 'pz';
let adjustedQty;
if (unit === 'pz' || unit === 'conf') {
adjustedQty = Math.max(1, Math.round(baseQty * pct));
} else {
adjustedQty = Math.round(baseQty * pct * 10) / 10;
}
document.getElementById('add-quantity').value = adjustedQty;
}
function selectLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('add-location').value = loc;
}
async function submitAdd(e) {
e.preventDefault();
showLoading(true);
try {
const selectedUnit = document.getElementById('add-unit').value;
const productUnit = currentProduct.unit || 'pz';
const result = await api('inventory_add', {}, 'POST', {
product_id: currentProduct.id,
quantity: parseFloat(document.getElementById('add-quantity').value) || 1,
location: document.getElementById('add-location').value,
expiry_date: document.getElementById('add-expiry').value || null,
unit: selectedUnit !== productUnit ? selectedUnit : null,
});
showLoading(false);
if (result.success) {
showToast(`β
${currentProduct.name} aggiunto!`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== USE FROM INVENTORY =====
function showUseForm() {
renderUsePreview();
document.getElementById('use-quantity').value = 1;
document.getElementById('use-location').value = 'dispensa';
// Reset location buttons
document.querySelectorAll('#page-use .loc-btn').forEach(b => b.classList.remove('active'));
document.querySelector('#page-use .loc-btn').classList.add('active');
loadUseInventoryInfo();
showPage('use');
}
function renderUsePreview() {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(currentProduct?.category, currentProduct?.name)] || 'π¦';
document.getElementById('use-product-preview').innerHTML = `
${currentProduct?.image_url ?
` ` :
`${catIcon} `
}
${escapeHtml(currentProduct?.name || '')}
${currentProduct?.brand ? escapeHtml(currentProduct.brand) : ''}
`;
}
async function loadUseInventoryInfo() {
try {
const data = await api('inventory_list');
const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id);
const infoEl = document.getElementById('use-inventory-info');
if (items.length > 0) {
infoEl.innerHTML = 'π¦ Disponibile: ' + items.map(i => {
const loc = LOCATIONS[i.location] || { icon: 'π¦', label: i.location };
return `${loc.icon} ${loc.label}: ${i.quantity} ${i.unit}`;
}).join(' Β· ');
} else {
infoEl.innerHTML = 'β οΈ Prodotto non presente nell\'inventario.';
}
} catch(e) {
console.error(e);
}
}
function selectUseLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('use-location').value = loc;
}
async function submitUseAll() {
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location: document.getElementById('use-location').value,
});
showLoading(false);
if (result.success) {
showToast(`π€ ${currentProduct.name} terminato!`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
async function submitUse(e) {
e.preventDefault();
showLoading(true);
try {
const qty = parseFloat(document.getElementById('use-quantity').value) || 1;
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
quantity: qty,
location: document.getElementById('use-location').value,
});
showLoading(false);
if (result.success) {
showToast(`π€ Usato ${qty} di ${currentProduct.name}. Rimasti: ${result.remaining}`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== AI IDENTIFICATION =====
async function captureForAI() {
stopScanner();
showPage('ai');
}
async function initAICamera() {
const video = document.getElementById('ai-video');
const captureDiv = document.getElementById('ai-capture');
const previewDiv = document.getElementById('ai-preview');
const captureBtn = document.getElementById('ai-capture-btn');
const retakeBtn = document.getElementById('ai-retake-btn');
const resultDiv = document.getElementById('ai-result');
captureDiv.style.display = 'block';
previewDiv.style.display = 'none';
captureBtn.style.display = 'block';
retakeBtn.style.display = 'none';
resultDiv.style.display = 'none';
try {
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
}
aiStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
video.srcObject = aiStream;
await video.play();
} catch (err) {
console.error('AI Camera error:', err);
showToast('Impossibile accedere alla fotocamera', 'error');
}
}
function takePhotoForAI() {
const video = document.getElementById('ai-video');
const canvas = document.getElementById('ai-canvas');
const img = document.getElementById('ai-image');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
img.src = dataUrl;
// Stop camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
aiStream = null;
}
video.srcObject = null;
document.getElementById('ai-capture').style.display = 'none';
document.getElementById('ai-preview').style.display = 'block';
document.getElementById('ai-capture-btn').style.display = 'none';
document.getElementById('ai-retake-btn').style.display = 'block';
// Immediately start analysis
analyzeWithAI();
}
function retakePhotoAI() {
document.getElementById('ai-result').style.display = 'none';
initAICamera();
}
async function analyzeWithAI() {
const resultDiv = document.getElementById('ai-result');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '
π€ Identifico il prodotto...
';
const canvas = document.getElementById('ai-canvas');
const base64 = canvas.toDataURL('image/jpeg', 0.7).split(',')[1];
try {
const result = await api('gemini_identify', {}, 'POST', { image: base64 });
if (!result.success) {
if (result.error === 'no_api_key') {
resultDiv.innerHTML = `β οΈ Chiave API Gemini non configurata.Aggiungi GEMINI_API_KEY nel file .env sul server.
`;
} else {
resultDiv.innerHTML = `β ${escapeHtml(result.error || 'Errore nell\'identificazione')}
π Riprova `;
}
return;
}
const id = result.identified;
const matches = result.off_matches || [];
let html = `π€ Prodotto identificato `;
html += ``;
html += `
${escapeHtml(id.name)} `;
if (id.brand) html += `
- ${escapeHtml(id.brand)} `;
if (id.description) html += `
${escapeHtml(id.description)}
`;
html += `
`;
if (matches.length > 0) {
html += `π¦ Prodotti corrispondenti `;
html += ``;
matches.forEach((m, idx) => {
html += `
`;
if (m.image_url) {
html += `
`;
}
html += `
`;
html += `${escapeHtml(m.name)} `;
if (m.brand) html += `${escapeHtml(m.brand)} `;
if (m.quantity_info) html += `${escapeHtml(m.quantity_info)} `;
html += `
`;
html += `
${m.barcode} `;
html += `
`;
});
html += `
`;
}
// Option to save as-is without barcode
html += ``;
html += `βοΈ Salva senza barcode `;
html += `
`;
resultDiv.innerHTML = html;
// Store data for later use
window._aiIdentified = id;
window._aiMatches = matches;
} catch (err) {
console.error('AI identify error:', err);
resultDiv.innerHTML = `β Errore di connessione
π Riprova `;
}
}
async function selectAIMatch(idx) {
const match = window._aiMatches[idx];
if (!match) return;
showLoading(true);
try {
// Use the barcode to do a full lookup (gets all details)
const localResult = await api('search_barcode', { barcode: match.barcode });
if (localResult.found) {
currentProduct = localResult.product;
showLoading(false);
showProductAction();
return;
}
// Full lookup via OpenFoodFacts
const lookupResult = await api('lookup_barcode', { barcode: match.barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
const detected = detectUnitAndQuantity(p.quantity_info);
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: notesParts.join(' Β· '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: match.barcode,
name: p.name || match.name,
brand: p.brand || match.brand || '',
category: p.category || '',
image_url: p.image_url || match.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
weight_info: p.quantity_info || '',
};
showLoading(false);
showProductAction();
return;
}
}
// Fallback: save with basic info from match
const saveResult = await api('product_save', {}, 'POST', {
barcode: match.barcode,
name: match.name,
brand: match.brand || '',
category: match.category || '',
image_url: match.image_url || '',
unit: 'pz',
default_quantity: 1,
});
if (saveResult.id) {
currentProduct = { id: saveResult.id, barcode: match.barcode, name: match.name, brand: match.brand || '', category: match.category || '', image_url: match.image_url || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showProductAction();
} else {
showLoading(false);
showToast('Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
console.error('AI match select error:', err);
showToast('Errore di connessione', 'error');
}
}
async function saveAIProductDirect() {
const id = window._aiIdentified;
if (!id) return;
showLoading(true);
try {
const result = await api('product_save', {}, 'POST', {
name: id.name,
brand: id.brand || '',
category: id.category || '',
unit: 'pz',
default_quantity: 1,
});
if (result.success || result.id) {
currentProduct = { id: result.id, name: id.name, brand: id.brand || '', category: id.category || '', unit: 'pz', default_quantity: 1 };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== ALL PRODUCTS =====
async function loadAllProducts() {
try {
const data = await api('products_list');
renderProductsList(data.products || []);
} catch (err) {
console.error(err);
}
}
async function searchAllProducts() {
const q = document.getElementById('products-search').value;
if (q.length < 2) {
loadAllProducts();
return;
}
const data = await api('products_search', { q });
renderProductsList(data.products || []);
}
function renderProductsList(products) {
const container = document.getElementById('products-list');
if (products.length === 0) {
container.innerHTML = 'π¦
Nessun prodotto nel database. Scansiona un prodotto per iniziare!
';
return;
}
container.innerHTML = products.map(p => {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(p.category, p.name)] || 'π¦';
return `
${p.image_url ? `
` : catIcon}
${escapeHtml(p.name)}
${p.brand ? `
${escapeHtml(p.brand)}
` : ''}
${p.barcode ? `π ${p.barcode} ` : ''}
${catIcon} ${p.category || 'Non categorizzato'}
`;
}).join('');
}
async function selectProductForAction(productId) {
showLoading(true);
try {
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
showLoading(false);
showProductAction();
} else {
showLoading(false);
showToast('Prodotto non trovato', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore', 'error');
}
}
// ===== SHOPPING LIST (BRING! INTEGRATION) =====
let shoppingListUUID = '';
let shoppingItems = [];
let suggestionItems = [];
async function loadShoppingList() {
const statusEl = document.getElementById('bring-status');
const currentEl = document.getElementById('shopping-current');
const suggestionsEl = document.getElementById('shopping-suggestions');
statusEl.style.display = 'block';
statusEl.innerHTML = '';
currentEl.style.display = 'none';
suggestionsEl.style.display = 'none';
try {
const data = await api('bring_list');
statusEl.style.display = 'none';
if (!data.success) {
statusEl.style.display = 'block';
statusEl.innerHTML = `β οΈ ${escapeHtml(data.error || 'Errore connessione Bring!')}
`;
return;
}
shoppingListUUID = data.listUUID;
shoppingItems = data.purchase || [];
renderShoppingItems();
currentEl.style.display = 'block';
} catch (err) {
console.error('Bring! error:', err);
statusEl.style.display = 'block';
statusEl.innerHTML = 'β οΈ Errore di connessione a Bring!
';
}
}
function renderShoppingItems() {
const container = document.getElementById('shopping-items');
const countEl = document.getElementById('shopping-count');
countEl.textContent = shoppingItems.length;
if (shoppingItems.length === 0) {
container.innerHTML = 'β
Lista della spesa vuota! Usa il pulsante sotto per generare suggerimenti.
';
return;
}
container.innerHTML = shoppingItems.map(item => {
const catIcon = CATEGORY_ICONS[guessCategoryFromName(item.name)] || 'π';
return `
`;
}).join('');
}
async function removeBringItem(name) {
try {
const data = await api('bring_remove', {}, 'POST', { name, listUUID: shoppingListUUID });
if (data.success) {
shoppingItems = shoppingItems.filter(i => i.name !== name);
renderShoppingItems();
showToast('Rimosso dalla lista', 'success');
}
} catch (err) {
showToast('Errore nella rimozione', 'error');
}
}
async function generateSuggestions() {
const btn = document.getElementById('btn-suggest');
const suggestionsEl = document.getElementById('shopping-suggestions');
btn.disabled = true;
btn.innerHTML = '
Analisi in corso...';
suggestionsEl.style.display = 'none';
try {
const data = await api('bring_suggest', {}, 'POST', {});
btn.disabled = false;
btn.innerHTML = 'π€ Suggerisci cosa comprare';
if (!data.success) {
showToast(data.error || 'Errore nella generazione', 'error');
return;
}
suggestionItems = (data.suggestions || []).map(s => ({ ...s, selected: true }));
// Show seasonal tip
const tipEl = document.getElementById('seasonal-tip');
if (data.seasonal_tip) {
tipEl.style.display = 'block';
tipEl.innerHTML = `πΏ ${escapeHtml(data.seasonal_tip)} `;
} else {
tipEl.style.display = 'none';
}
renderSuggestions();
suggestionsEl.style.display = 'block';
document.getElementById('suggestion-actions').style.display = 'block';
// Scroll to suggestions
suggestionsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (err) {
btn.disabled = false;
btn.innerHTML = 'π€ Suggerisci cosa comprare';
console.error('Suggestion error:', err);
showToast('Errore di connessione', 'error');
}
}
function renderSuggestions() {
const container = document.getElementById('suggestion-items');
const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 };
const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2));
container.innerHTML = sorted.map((item, idx) => {
const catIcon = CATEGORY_ICONS[item.category] || 'π';
const priorityBadge = {
'alta': 'Alta ',
'media': 'Media ',
'bassa': 'Bassa ',
}[item.priority] || '';
return `
${item.selected ? 'βοΈ' : 'β¬'}
${catIcon}
${escapeHtml(item.name)}${item.specification ? ` (${escapeHtml(item.specification)}) ` : ''} ${priorityBadge}
${escapeHtml(item.reason)}
`;
}).join('');
updateSuggestionActionBtn();
}
function toggleSuggestion(idx) {
const priorityOrder = { 'alta': 0, 'media': 1, 'bassa': 2 };
const sorted = [...suggestionItems].sort((a, b) => (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2));
const actualItem = sorted[idx];
// Find in original array
const origIdx = suggestionItems.indexOf(actualItem);
if (origIdx >= 0) {
suggestionItems[origIdx].selected = !suggestionItems[origIdx].selected;
}
renderSuggestions();
}
function updateSuggestionActionBtn() {
const selected = suggestionItems.filter(s => s.selected);
const btn = document.querySelector('#suggestion-actions .btn-success');
if (btn) {
btn.textContent = `β
Aggiungi ${selected.length} prodott${selected.length === 1 ? 'o' : 'i'} a Bring!`;
btn.disabled = selected.length === 0;
}
}
async function addSelectedSuggestions() {
const selected = suggestionItems.filter(s => s.selected);
if (selected.length === 0) {
showToast('Seleziona almeno un prodotto', 'error');
return;
}
const btn = document.querySelector('#suggestion-actions .btn-success');
btn.disabled = true;
btn.innerHTML = '
Aggiunta in corso...';
try {
const items = selected.map(s => ({
name: s.name,
specification: s.specification || '',
}));
const data = await api('bring_add', {}, 'POST', { items, listUUID: shoppingListUUID });
if (data.success) {
showToast(`${data.added} prodott${data.added === 1 ? 'o aggiunto' : 'i aggiunti'} a Bring!`, 'success');
// Refresh list
await loadShoppingList();
// Clear suggestions
document.getElementById('shopping-suggestions').style.display = 'none';
suggestionItems = [];
} else {
showToast(data.error || 'Errore', 'error');
}
} catch (err) {
showToast('Errore di connessione', 'error');
}
btn.disabled = false;
btn.innerHTML = 'β
Aggiungi selezionati a Bring!';
}
// ===== UTILITY FUNCTIONS =====
// ===== SCAN EXPIRY DATE WITH CAMERA + GEMINI AI =====
let expiryStream = null;
async function scanExpiryWithAI() {
// Create modal for camera capture
document.getElementById('modal-content').innerHTML = `
Inquadra la data di scadenza stampata sul prodotto
π€ Analisi AI in corso...
πΈ Scatta Foto
π Riscatta
`;
document.getElementById('modal-overlay').style.display = 'flex';
// Start camera
try {
expiryStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
const video = document.getElementById('expiry-video');
video.srcObject = expiryStream;
await video.play();
} catch (err) {
console.error('Expiry camera error:', err);
document.getElementById('expiry-cam-container').innerHTML = `
β οΈ Impossibile accedere alla fotocamera
`;
}
}
function closeExpiryScanner() {
if (expiryStream) {
expiryStream.getTracks().forEach(t => t.stop());
expiryStream = null;
}
closeModal();
}
function captureExpiry() {
const video = document.getElementById('expiry-video');
const canvas = document.getElementById('expiry-canvas');
const img = document.getElementById('expiry-preview-img');
// Crop to center 50% (matching the 2x zoom view) for better AI accuracy
const sw = video.videoWidth / 2;
const sh = video.videoHeight / 2;
const sx = (video.videoWidth - sw) / 2;
const sy = (video.videoHeight - sh) / 2;
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, sw, sh);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
img.src = dataUrl;
// Stop camera
if (expiryStream) {
expiryStream.getTracks().forEach(t => t.stop());
expiryStream = null;
}
video.srcObject = null;
document.getElementById('expiry-cam-container').style.display = 'none';
document.getElementById('expiry-preview-container').style.display = 'block';
document.getElementById('expiry-capture-btn').style.display = 'none';
document.getElementById('expiry-retake-btn').style.display = 'block';
// Auto-analyze
analyzeExpiryImage(dataUrl);
}
function retakeExpiry() {
document.getElementById('expiry-cam-container').style.display = 'block';
document.getElementById('expiry-preview-container').style.display = 'none';
document.getElementById('expiry-capture-btn').style.display = 'block';
document.getElementById('expiry-retake-btn').style.display = 'none';
document.getElementById('expiry-scan-status').style.display = 'none';
// Restart camera
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
}).then(stream => {
expiryStream = stream;
const video = document.getElementById('expiry-video');
video.srcObject = stream;
video.play();
}).catch(err => console.error(err));
}
async function analyzeExpiryImage(dataUrl) {
const statusDiv = document.getElementById('expiry-scan-status');
statusDiv.style.display = 'block';
statusDiv.innerHTML = '
π€ Analisi AI in corso...
';
try {
// Remove data:image/jpeg;base64, prefix
const base64 = dataUrl.split(',')[1];
const result = await api('gemini_expiry', {}, 'POST', { image: base64 });
if (result.success && result.expiry_date) {
// Auto-fill the expiry date
const expiryInput = document.getElementById('add-expiry');
if (expiryInput) {
expiryInput.value = result.expiry_date;
}
statusDiv.innerHTML = `β
Data trovata: ${formatDate(result.expiry_date)}
`;
// Close modal after delay
setTimeout(() => closeExpiryScanner(), 1500);
} else if (result.error === 'no_api_key') {
statusDiv.innerHTML = `β οΈ Chiave API Gemini non configurata.Aggiungi GEMINI_API_KEY nel file .env sul server.
`;
} else {
statusDiv.innerHTML = `β Non riesco a leggere la data. ${result.raw_text ? 'Letto: ' + escapeHtml(result.raw_text) + ' ' : ''}
π Riprova `;
}
} catch (err) {
console.error('Expiry AI error:', err);
statusDiv.innerHTML = `β Errore di connessione. Riprova.
π Riprova `;
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
}
function formatDateTime(dtStr) {
if (!dtStr) return '';
const d = new Date(dtStr.replace(' ', 'T'));
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short' }) + ' ' +
d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
}
function daysUntilExpiry(dateStr) {
if (!dateStr) return Infinity;
const expiry = new Date(dateStr + 'T00:00:00');
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((expiry - today) / 86400000);
}
function adjustQty(inputId, delta) {
const input = document.getElementById(inputId);
let val = parseFloat(input.value) || 0;
val = Math.max(0.1, val + delta);
input.value = Math.round(val * 10) / 10;
}
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'flex' : 'none';
}
function showToast(message, type = '') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show ' + type;
setTimeout(() => {
toast.className = 'toast';
}, 3000);
}
// ===== RECIPE GENERATION =====
function getMealType() {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) return 'colazione';
if (hour >= 11 && hour < 16) return 'pranzo';
return 'cena';
}
const MEAL_LABELS = {
'colazione': 'βοΈ Colazione',
'pranzo': 'π½οΈ Pranzo',
'cena': 'π Cena'
};
function openRecipeDialog() {
const meal = getMealType();
document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || 'π³ Ricetta';
document.getElementById('recipe-persons').value = 1;
document.getElementById('recipe-ask').style.display = '';
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = 'none';
document.getElementById('recipe-overlay').style.display = 'flex';
}
function closeRecipeDialog() {
document.getElementById('recipe-overlay').style.display = 'none';
}
function adjustRecipePersons(delta) {
const input = document.getElementById('recipe-persons');
let val = parseInt(input.value) || 1;
val = Math.max(1, Math.min(20, val + delta));
input.value = val;
}
async function generateRecipe() {
const meal = getMealType();
const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
document.getElementById('recipe-ask').style.display = 'none';
document.getElementById('recipe-loading').style.display = '';
document.getElementById('recipe-result').style.display = 'none';
try {
const result = await api('generate_recipe', {}, 'POST', { meal, persons });
if (!result.success) {
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
if (result.error === 'no_api_key') {
showToast('β οΈ Chiave API Gemini non configurata', 'warning');
} else {
showToast(result.error || 'Errore nella generazione', 'error');
}
return;
}
const r = result.recipe;
let html = `${r.title} `;
// Meta tags
html += '';
html += `${MEAL_LABELS[r.meal] || r.meal} `;
html += `π₯ ${r.persons} pers. `;
if (r.prep_time) html += `πͺ ${r.prep_time} `;
if (r.cook_time) html += `π₯ ${r.cook_time} `;
if (r.tags) r.tags.forEach(t => { html += `${t} `; });
html += '
';
// Expiry note
if (r.expiry_note) {
html += `β οΈ ${r.expiry_note}
`;
}
// Ingredients
html += 'π§Ύ Ingredienti ';
(r.ingredients || []).forEach(ing => {
const pantryIcon = ing.from_pantry ? ' β
' : ' π';
html += `${ing.name} : ${ing.qty}${pantryIcon} `;
});
html += ' ';
// Steps
html += 'π¨βπ³ Procedimento ';
(r.steps || []).forEach(step => {
// Remove leading "Passo N:" if present
const cleanStep = step.replace(/^Passo\s*\d+\s*:\s*/i, '');
html += `${cleanStep} `;
});
html += ' ';
// Nutrition note
if (r.nutrition_note) {
html += `π‘ ${r.nutrition_note}
`;
}
document.getElementById('recipe-content').innerHTML = html;
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = '';
} catch (err) {
console.error('Recipe error:', err);
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
showToast('Errore di connessione', 'error');
}
}
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
showPage('dashboard');
});