diff --git a/api/database.php b/api/database.php index fbc2845..016a9d5 100644 --- a/api/database.php +++ b/api/database.php @@ -217,47 +217,90 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc $cat = mb_strtolower($category); $loc = mb_strtolower($location); - // Freezer: opened items still last a long time - if ($loc === 'freezer') return 90; - // Dispensa: opened dry goods - if ($loc === 'dispensa') return 30; + // ── A: Non-perishables — check BEFORE location so dispensa doesn't swallow them ── + if (preg_match('/\bsale\b|\bsel\s+mar|\bsalt\b/', $n) && !preg_match('/\b(salmone|salame|salsa)\b/', $n)) return 9999; + if (preg_match('/\bzucchero\b|\bsugar\b/', $n)) return 9999; + if (preg_match('/\bmiele\b/', $n)) return 9999; + if (preg_match('/\baceto\b/', $n)) return 9999; // all vinegars + if (preg_match('/\bbicarbonato\b|\blievito\s+chimico\b/', $n)) return 9999; - // Specific product overrides (fridge) + // ── B: High-ABV spirits ────────────────────────────────────────────── + if (preg_match('/\b(sambuca|rum\b|brandy|whiskey|whisky|vodka|gin\b|grappa|amaro|aperol|campari|limoncello|cognac|porto|marsala|baileys|amaretto|vermouth)\b/', $n)) return 730; + + // ── C: Long-life regardless of location ───────────────────────────── + if (preg_match('/\b(aroma|estratto|essenza|vanilli|colorante)\b/', $n)) return 730; + if (preg_match('/\b(t[eè]\b|tea\b|tisana|camomilla|verbena|infuso|rooibos)\b/', $n)) return 730; + if (preg_match('/\b(caff[eè]|coffee|nespresso)\b/', $n)) return 365; + if (preg_match('/\bolio\b/', $n)) return 365; + if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90; // soy sauce fine opened anywhere + // Dry goods only outside fridge (uncooked) + if ($loc !== 'frigo') { + if (preg_match('/\b(pasta|spaghetti|penne|rigatoni|fusilli|farfalle|tagliatelle|linguine|bucatini|lasagn|tortiglioni)\b/', $n)) return 365; + if (preg_match('/\b(riso|risotto|orzo|farro|quinoa|couscous)\b/', $n) && !preg_match('/\b(pronto|cotto)\b/', $n)) return 365; + if (preg_match('/\b(polenta|semola|maizena|amido|farina)\b/', $n)) return 180; + if (preg_match('/\b(lenticchie|ceci|fagioli|piselli)\b/', $n) && !preg_match('/\b(cotto|vapore|scatola)\b/', $n)) return 365; + } + + // ── D: Freezer ─────────────────────────────────────────────────────── + if ($loc === 'freezer') return 90; + + // ── E: Pantry/dispensa — specific products then generic fallback ───── + if ($loc !== 'frigo') { + if (preg_match('/\b(biscott[io]|cookies|wafer|tarall[io]|crackers?)\b/', $n)) return 60; + if (preg_match('/\b(muesli|cereali|corn\s*flakes|granola|fiocchi)\b/', $n)) return 60; + if (preg_match('/\b(confettura|marmellata)\b/', $n)) return 90; + if (preg_match('/\b(nutella|cioccolat)\b/', $n)) return 90; + if (preg_match('/\bpane\b/', $n)) return 4; + // Specific jarred tomato sauce in pantry (opened, not refrigerated) + if (preg_match('/salsa\s+di\s+(pomodoro|pronta)/', $n)) return 5; + return 60; // generic pantry fallback (was 30, doubled) + } + + // ── F: Fridge — short-life perishables ────────────────────────────── if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) return 3; - if (preg_match('/latte\s+uht|latte\s+a\s+lunga/', $n)) return 5; - if (preg_match('/latte/', $n)) return 4; - if (preg_match('/yogurt/', $n)) return 3; - if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 2; + if (preg_match('/latte\s+(uht|a\s+lunga)/', $n)) return 5; + if (preg_match('/\blatte\b/', $n)) return 4; + if (preg_match('/\byogurt\b/', $n)) return 5; + if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3; if (preg_match('/philadelphia|spalmabile/', $n)) return 7; if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5; if (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) return 21; if (preg_match('/formaggio/', $n)) return 10; - if (preg_match('/burro/', $n)) return 21; - if (preg_match('/panna/', $n)) return 3; - if (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) return 3; + if (preg_match('/\bburro\b/', $n)) return 30; + if (preg_match('/\bpanna\b/', $n)) return 4; + if (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) return 5; if (preg_match('/prosciutto\s+crudo|salame|bresaola|speck|pancetta|nduja/', $n)) return 7; - if (preg_match('/pollo|tacchino|maiale|manzo|vitello/', $n)) return 2; - if (preg_match('/salmone|tonno\s+fresco|pesce/', $n)) return 2; - if (preg_match('/passata|pelati|polpa|sugo/', $n)) return 5; - if (preg_match('/marmellata|confettura/', $n)) return 30; - if (preg_match('/miele/', $n)) return 180; - if (preg_match('/nutella/', $n)) return 60; - if (preg_match('/succo|spremuta/', $n)) return 4; - if (preg_match('/olio|aceto/', $n)) return 90; - if (preg_match('/vino|birra/', $n)) return 5; - if (preg_match('/limone|limmi/', $n)) return 21; - if (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) return 3; - if (preg_match('/insalata|rucola|spinaci/', $n)) return 3; + if (preg_match('/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/', $n)) return 2; + if (preg_match('/salmone|tonno\s+fresco|pesce(?!\s+in)/', $n)) return 2; + if (preg_match('/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/', $n)) return 5; + if (preg_match('/insalata|rucola|spinaci|lattuga/', $n)) return 4; + if (preg_match('/\b(succo|spremuta)\b/', $n)) return 5; + if (preg_match('/\blimone\b/', $n)) return 14; + if (preg_match('/\b(birra|beer)\b/', $n)) return 3; + if (preg_match('/\bvino\b/', $n)) return 5; + if (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) return 4; + if (preg_match('/\b(banana|banane|mela|pera|pesca|albicocca|ciliegia|uva|fragola|lampone|kiwi)\b/', $n)) return 10; + if (preg_match('/\b(arancia|mandarino|pompelmo|clementina)\b/', $n)) return 14; + if (preg_match('/\b(carota|zucchina|peperone|melanzana|broccolo|cavolfiore|sedano|finocchio)\b/', $n)) return 7; - // Category fallbacks - if (preg_match('/dairy|latticin|lait|dairies/', $cat)) return 5; - if (preg_match('/meat|carne|meats/', $cat)) return 3; + // ── G: Fridge condiments — medium shelf-life ───────────────────────── + if (preg_match('/maionese|mayo|mayon/', $n)) return 90; + if (preg_match('/\bketchup\b/', $n)) return 90; + if (preg_match('/\b(senape|mustard)\b/', $n)) return 90; + if (preg_match('/salsa\s+di\s+soia|soy\s*sauce/', $n)) return 90; + if (preg_match('/\b(tabasco|worcestershire|sriracha)\b/', $n)) return 180; + if (preg_match('/confettura|marmellata/', $n)) return 60; + if (preg_match('/nutella|cioccolat/', $n)) return 60; + + // ── H: Category fallbacks ──────────────────────────────────────────── + if (preg_match('/dairy|latticin/', $cat)) return 5; + if (preg_match('/meat|carne/', $cat)) return 3; if (preg_match('/fish|pesce/', $cat)) return 2; - if (preg_match('/fruit|frutta/', $cat)) return 5; - if (preg_match('/verdur|vegetable|plant-based/', $cat)) return 5; - if (preg_match('/conserve/', $cat)) return 5; - if (preg_match('/condimenti|sauce/', $cat)) return 21; - if (preg_match('/bevand|beverage/', $cat)) return 4; + if (preg_match('/fruit|frutta/', $cat)) return 7; + if (preg_match('/verdur|vegetable/', $cat)) return 5; + if (preg_match('/conserve/', $cat)) return 7; + if (preg_match('/condimenti|sauce/', $cat)) return 30; + if (preg_match('/bevand|beverage/', $cat)) return 5; return 5; // safe default for fridge } diff --git a/api/index.php b/api/index.php index ae33eb7..6407e9d 100644 --- a/api/index.php +++ b/api/index.php @@ -800,6 +800,10 @@ function useFromInventory(PDO $db): void { $openedDays = estimateOpenedExpiryDaysPHP($prodInfo['name'] ?? '', $prodInfo['category'] ?? '', $location); if ($vacuum) $openedDays = (int)round($openedDays * 1.5); $openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days")); + // Respect original sealed expiry if it expires sooner + if (!empty($origRow['expiry_date']) && strtotime($origRow['expiry_date']) < strtotime($openedExpiry)) { + $openedExpiry = $origRow['expiry_date']; + } $stmt3 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"); $stmt3->execute([$productId, $location, $newFraction, $openedExpiry, $vacuum]); $openedId = (int)$db->lastInsertId(); @@ -843,6 +847,10 @@ function useFromInventory(PDO $db): void { $openedDays = estimateOpenedExpiryDaysPHP($pName, $pCat, $location); if ($vacuum) $openedDays = (int)round($openedDays * 1.5); $openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days")); + // Respect original sealed expiry if it expires sooner + if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) { + $openedExpiry = $existing['expiry_date']; + } $stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$newQty, $openedExpiry, $existing['id']]); } else { @@ -1089,12 +1097,9 @@ function getStats(PDO $db): void { $openedDays = estimateOpenedExpiryDaysPHP($item['name'], $item['category'], $item['location']); if ($vacuum) $openedDays = (int)round($openedDays * 1.5); $computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400; - // Use the sooner of computed opened expiry vs original sealed expiry - if ($originalExpiry !== null) { - $finalExpiry = min($computedExpiry, $originalExpiry); - } else { - $finalExpiry = $computedExpiry; - } + // Use the computed opened expiry only — stored expiry_date may have been set by + // an older (inaccurate) estimation and would give wrong results if mixed in. + $finalExpiry = $computedExpiry; $item['opened_expiry'] = date('Y-m-d', $finalExpiry); $item['days_to_expiry'] = (int)round(($finalExpiry - $today) / 86400); } else { @@ -1106,6 +1111,8 @@ function getStats(PDO $db): void { } $item['is_edible'] = $item['days_to_expiry'] === null || $item['days_to_expiry'] >= 0; $item['has_opened_at'] = !empty($item['opened_at']); + // Hide non-perishable items (salt, sugar, spirits, oil, etc.) — they won't expire usefully + if ($item['days_to_expiry'] !== null && $item['days_to_expiry'] > 365) continue; // Hide legacy fractional items (no opened_at) with far-off expiry — not useful for home widget if (!$item['has_opened_at'] && ($item['days_to_expiry'] === null || $item['days_to_expiry'] > 14)) continue; $opened[] = $item; diff --git a/assets/js/app.js b/assets/js/app.js index 223e063..c6a91f6 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -396,47 +396,85 @@ function estimateOpenedExpiryDays(product, location) { const cat = (product.category || '').toLowerCase(); const loc = (location || '').toLowerCase(); - // Freezer: opened items still last a long time - if (loc === 'freezer') return 90; - // Dispensa: opened dry goods - if (loc === 'dispensa') return 30; + // ── A: Non-perishables — check BEFORE location ────────────────────── + if (/\bsale\b|\bsel\s+mar|\bsalt\b/.test(name) && !/\b(salmone|salame|salsa)\b/.test(name)) return 9999; + if (/\bzucchero\b|\bsugar\b/.test(name)) return 9999; + if (/\bmiele\b/.test(name)) return 9999; + if (/\baceto\b/.test(name)) return 9999; + if (/\bbicarbonato\b|\blievito\s+chimico\b/.test(name)) return 9999; + + // ── B: Spirits ─────────────────────────────────────────────────────── + if (/\b(sambuca|rum\b|brandy|whiskey|whisky|vodka|gin\b|grappa|amaro|aperol|campari|limoncello|cognac|porto|marsala|baileys|amaretto|vermouth)\b/.test(name)) return 730; + + // ── C: Long-life regardless of location ───────────────────────────── + if (/\b(aroma|estratto|essenza|vanilli|colorante)\b/.test(name)) return 730; + if (/\b(t[eè]\b|tea\b|tisana|camomilla|verbena|infuso|rooibos)\b/.test(name)) return 730; + if (/\b(caff[eè]|coffee|nespresso)\b/.test(name)) return 365; + if (/\bolio\b/.test(name)) return 365; + if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90; // soy sauce fine opened anywhere + if (loc !== 'frigo') { + if (/\b(pasta|spaghetti|penne|rigatoni|fusilli|farfalle|tagliatelle|linguine|bucatini|lasagn|tortiglioni)\b/.test(name)) return 365; + if (/\b(riso|risotto|orzo|farro|quinoa|couscous)\b/.test(name) && !/\b(pronto|cotto)\b/.test(name)) return 365; + if (/\b(polenta|semola|maizena|amido|farina)\b/.test(name)) return 180; + } + + // ── D: Freezer ─────────────────────────────────────────────────────── + if (loc === 'freezer') return 90; + + // ── E: Pantry fallbacks ─────────────────────────────────────────────── + if (loc !== 'frigo') { + if (/\b(biscott[io]|cookies|wafer|tarall[io]|crackers?)\b/.test(name)) return 60; + if (/\b(muesli|cereali|corn\s*flakes|granola|fiocchi)\b/.test(name)) return 60; + if (/\b(confettura|marmellata)\b/.test(name)) return 90; + if (/\b(nutella|cioccolat)\b/.test(name)) return 90; + if (/\bpane\b/.test(name)) return 4; + return 60; + } - // Specific product overrides (fridge) if (/latte\s+(fresco|intero|parzial|scremato)/.test(name)) return 3; - if (/latte\s+uht|latte\s+a\s+lunga/.test(name)) return 5; - if (/latte/.test(name)) return 4; - if (/yogurt/.test(name)) return 3; - if (/mozzarella|burrata|stracciatella/.test(name)) return 2; + if (/latte\s+(uht|a\s+lunga)/.test(name)) return 5; + if (/\blatte\b/.test(name)) return 4; + if (/\byogurt\b/.test(name)) return 5; + if (/mozzarella|burrata|stracciatella/.test(name)) return 3; if (/philadelphia|spalmabile/.test(name)) return 7; if (/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 5; if (/parmigiano|grana|pecorino|provolone/.test(name)) return 21; if (/formaggio/.test(name)) return 10; - if (/burro/.test(name)) return 21; - if (/panna/.test(name)) return 3; - if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) return 3; + if (/\bburro\b/.test(name)) return 30; + if (/\bpanna\b/.test(name)) return 4; + if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) return 5; if (/prosciutto\s+crudo|salame|bresaola|speck|pancetta|nduja/.test(name)) return 7; - if (/pollo|tacchino|maiale|manzo|vitello/.test(name)) return 2; - if (/salmone|tonno\s+fresco|pesce/.test(name)) return 2; - if (/passata|pelati|polpa|sugo/.test(name)) return 5; - if (/marmellata|confettura/.test(name)) return 30; - if (/miele/.test(name)) return 180; - if (/nutella/.test(name)) return 60; - if (/succo|spremuta/.test(name)) return 4; - if (/olio|aceto/.test(name)) return 90; - if (/vino|birra/.test(name)) return 5; - if (/limone|limmi/.test(name)) return 21; - if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) return 3; - if (/insalata|rucola|spinaci/.test(name)) return 3; + if (/\b(pollo|tacchino|maiale|manzo|vitello|agnello)\b/.test(name)) return 2; + if (/salmone|tonno\s+fresco|pesce(?!\s+in)/.test(name)) return 2; + if (/\b(passata|pelati|polpa|sugo|salsa\s+di\s+pomodoro)\b/.test(name)) return 5; + if (/insalata|rucola|spinaci|lattuga/.test(name)) return 4; + if (/\b(succo|spremuta)\b/.test(name)) return 5; + if (/\blimone\b/.test(name)) return 14; + if (/\b(birra|beer)\b/.test(name)) return 3; + if (/\bvino\b/.test(name)) return 5; + if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) return 4; + if (/\b(banana|banane|mela|pera|pesca|albicocca|ciliegia|uva|fragola|lampone|kiwi)\b/.test(name)) return 10; + if (/\b(arancia|mandarino|pompelmo|clementina)\b/.test(name)) return 14; + if (/\b(carota|zucchina|peperone|melanzana|broccolo|cavolfiore|sedano|finocchio)\b/.test(name)) return 7; - // Category fallbacks - if (/dairy|latticin|lait|dairies/.test(cat)) return 5; - if (/meat|carne|meats/.test(cat)) return 3; + // ── G: Fridge condiments ───────────────────────────────────────────── + if (/maionese|mayo|mayon/.test(name)) return 90; + if (/\bketchup\b/.test(name)) return 90; + if (/\b(senape|mustard)\b/.test(name)) return 90; + if (/salsa\s+di\s+soia|soy\s*sauce/.test(name)) return 90; + if (/\b(tabasco|worcestershire|sriracha)\b/.test(name)) return 180; + if (/confettura|marmellata/.test(name)) return 60; + if (/nutella|cioccolat/.test(name)) return 60; + + // ── H: Category fallbacks ──────────────────────────────────────────── + if (/dairy|latticin/.test(cat)) return 5; + if (/meat|carne/.test(cat)) return 3; if (/fish|pesce/.test(cat)) return 2; - if (/fruit|frutta/.test(cat)) return 5; - if (/verdur|vegetable|plant-based/.test(cat)) return 5; - if (/conserve/.test(cat)) return 5; - if (/condimenti|sauce/.test(cat)) return 21; - if (/bevand|beverage/.test(cat)) return 4; + if (/fruit|frutta/.test(cat)) return 7; + if (/verdur|vegetable/.test(cat)) return 5; + if (/conserve/.test(cat)) return 7; + if (/condimenti|sauce/.test(cat)) return 30; + if (/bevand|beverage/.test(cat)) return 5; return 5; // safe default for fridge } @@ -1098,8 +1136,10 @@ async function loadDashboard() { const frac = Math.round((qty - wholeConf) * 1000) / 1000; const remainderAmt = frac * pkgSize; const remainderText = formatSubRemainder(remainderAmt, pkgUnit); - if (wholeConf > 0) { + if (wholeConf > 0 && remainderAmt >= 1) { qtyText = `${wholeConf} conf (da ${pkgSize}${pkgLabel}) + ${remainderText}`; + } else if (wholeConf > 0) { + qtyText = `${wholeConf} conf (da ${pkgSize}${pkgLabel})`; } else { qtyText = remainderText; } @@ -1125,6 +1165,9 @@ async function loadDashboard() { if (!isEdible) { expiryClass = 'opened-expiry-spoiled'; expiryText = '⛔ Scaduto!'; + } else if (days > 365) { + expiryClass = 'opened-expiry-ok'; + expiryText = '✅ Stabile'; } else if (days === 0) { expiryClass = 'opened-expiry-today'; expiryText = '⚠️ Scade oggi!';