From 19489a02653340c6438dffa79df583abe304425d Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Wed, 8 Apr 2026 12:30:36 +0000 Subject: [PATCH] Smart opened-product expiry: days countdown, edibility, correct sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP getStats() opened section: - Primary detection: opened_at IS NOT NULL (reliable, set by useFromInventory) Fallback: fractional-qty pattern (legacy items) - Per-item compute opened_expiry = min(opened_at + estimateOpenedExpiryDaysPHP, original_expiry) → vacuum_sealed items get 1.5× multiplier → always take sooner of 'opened shelf life' vs 'original sealed expiry' - Add days_to_expiry, opened_expiry, is_edible, has_opened_at to each item - Filter legacy items (no opened_at) with expiry > 14 days (too much noise) - Sort by days_to_expiry ASC (soonest/spoiled first) instead of updated_at DESC JS dashboard opened render: - Expiry badge: ⛔ Scaduto / ⚠️ Scade oggi / ⏰ Xgg (urgent≤2, soon≤5, ok>5) - 🔒 icon added when vacuum_sealed=1 - Spoiled items shown with strikethrough name + muted styling (.alert-item-spoiled) - Cap display at 10 items; 'e altri N prodotti aperti...' note if more - Sort comes from server (removed JS openedFraction sort) CSS: - .opened-expiry-{ok,soon,urgent,today,spoiled} badge classes - .alert-item-spoiled strikethrough styling - .alert-more-note --- api/index.php | 69 +++++++++++++++++++++++++++++++++++++------- assets/css/style.css | 28 ++++++++++++++++++ assets/js/app.js | 43 ++++++++++++++++++++++----- 3 files changed, 121 insertions(+), 19 deletions(-) diff --git a/api/index.php b/api/index.php index 6a50f7e..ae33eb7 100644 --- a/api/index.php +++ b/api/index.php @@ -1055,24 +1055,71 @@ function getStats(PDO $db): void { ORDER BY i.expiry_date ASC ")->fetchAll(); - // Opened (partially used items with known package capacity) - $opened = $db->query(" + // Opened (items with opened_at set by the app, OR fractional-qty items as legacy fallback) + // opened_at IS NOT NULL → already has recalculated expiry_date stored when first opened + $openedRaw = $db->query(" SELECT i.*, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit, p.image_url, COALESCE(i.vacuum_sealed, 0) as vacuum_sealed - FROM inventory i JOIN products p ON i.product_id = p.id - WHERE i.quantity > 0 AND p.default_quantity > 0 + FROM inventory i JOIN products p ON i.product_id = p.id + WHERE i.quantity > 0 AND ( - -- conf products with fractional quantity - (p.unit = 'conf' AND p.package_unit IS NOT NULL - AND CAST(i.quantity AS REAL) != CAST(CAST(i.quantity AS INTEGER) AS REAL)) + -- Primary: tracked as opened by the app (expiry_date already recalculated) + i.opened_at IS NOT NULL OR - -- non-conf products where quantity is not a clean multiple of package size (>2% tolerance) - (p.unit != 'conf' - AND ABS(i.quantity - ROUND(CAST(i.quantity AS REAL) / p.default_quantity) * p.default_quantity) > (p.default_quantity * 0.02)) + -- Fallback: fractional quantity pattern (legacy items before opened_at tracking) + (p.default_quantity > 0 AND ( + (p.unit = 'conf' AND p.package_unit IS NOT NULL + AND CAST(i.quantity AS REAL) != CAST(CAST(i.quantity AS INTEGER) AS REAL)) + OR + (p.unit != 'conf' + AND ABS(i.quantity - ROUND(CAST(i.quantity AS REAL) / p.default_quantity) * p.default_quantity) > (p.default_quantity * 0.02)) + )) ) - ORDER BY i.updated_at DESC ")->fetchAll(); + // Compute opened_expiry and days_to_expiry for each opened item + $opened = []; + $today = strtotime('today midnight'); + foreach ($openedRaw as $item) { + $vacuum = (int)($item['vacuum_sealed'] ?? 0); + $originalExpiry = !empty($item['expiry_date']) ? strtotime($item['expiry_date']) : null; + + if (!empty($item['opened_at'])) { + // Compute the opened shelf-life from the moment it was opened + $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; + } + $item['opened_expiry'] = date('Y-m-d', $finalExpiry); + $item['days_to_expiry'] = (int)round(($finalExpiry - $today) / 86400); + } else { + // Legacy: no opened_at, use stored expiry_date as-is + $item['opened_expiry'] = $item['expiry_date'] ?? null; + $item['days_to_expiry'] = $originalExpiry !== null + ? (int)round(($originalExpiry - $today) / 86400) + : null; + } + $item['is_edible'] = $item['days_to_expiry'] === null || $item['days_to_expiry'] >= 0; + $item['has_opened_at'] = !empty($item['opened_at']); + // 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; + } + // Sort by days_to_expiry ascending (soonest first; nulls last) + usort($opened, function($a, $b) { + $da = $a['days_to_expiry']; + $db2 = $b['days_to_expiry']; + if ($da === null && $db2 === null) return 0; + if ($da === null) return 1; + if ($db2 === null) return -1; + return $da <=> $db2; + }); + // Waste vs consumption stats (last 30 days) $wasteStats = $db->query(" SELECT type, COUNT(*) as count diff --git a/assets/css/style.css b/assets/css/style.css index fcd0402..7d4ad32 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -4304,6 +4304,34 @@ body { font-size: 0.7rem; } +/* Opened expiry badges */ +.alert-item-badge.opened-expiry { + font-size: 0.7rem; + font-weight: 700; + border-radius: 20px; + padding: 2px 7px; +} +.opened-expiry-ok { background: #dcfce7; color: #166534; } +.opened-expiry-soon { background: #fef9c3; color: #854d0e; } +.opened-expiry-urgent { background: #fee2e2; color: #991b1b; } +.opened-expiry-today { background: #f97316; color: #fff; } +.opened-expiry-spoiled { background: #1f2937; color: #f9fafb; } + +.alert-item-spoiled { + opacity: 0.75; +} +.alert-item-spoiled .alert-item-name { + text-decoration: line-through; + color: var(--text-light); +} + +.alert-more-note { + font-size: 0.8rem; + color: var(--text-light); + text-align: center; + padding: 6px 0 2px; +} + .review-hint { font-size: 0.8rem; color: #92400e; diff --git a/assets/js/app.js b/assets/js/app.js index 6b0c5f8..223e063 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1079,13 +1079,12 @@ async function loadDashboard() { const openedSection = document.getElementById('alert-opened'); const openedList = document.getElementById('opened-list'); if (statsData.opened && statsData.opened.length > 0) { - // Sort by remaining fraction ascending (least remaining first) - statsData.opened.sort((a, b) => { - const fA = openedFraction(a), fB = openedFraction(b); - return fA - fB; - }); + // Sorted server-side by days_to_expiry ASC openedSection.style.display = 'block'; - openedList.innerHTML = statsData.opened.map(item => { + const MAX_SHOWN = 10; + const visible = statsData.opened.slice(0, MAX_SHOWN); + const extra = statsData.opened.length - visible.length; + openedList.innerHTML = visible.map(item => { const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location }; const qty = parseFloat(item.quantity); const pkgSize = parseFloat(item.default_quantity); @@ -1116,8 +1115,35 @@ async function loadDashboard() { qtyText = `${qty}${unitLabel}`; } } + + // Expiry badge + const days = item.days_to_expiry; + const isEdible = item.is_edible; + let expiryBadge = ''; + if (days !== null && days !== undefined) { + let expiryClass, expiryText; + if (!isEdible) { + expiryClass = 'opened-expiry-spoiled'; + expiryText = '⛔ Scaduto!'; + } else if (days === 0) { + expiryClass = 'opened-expiry-today'; + expiryText = '⚠️ Scade oggi!'; + } else if (days <= 2) { + expiryClass = 'opened-expiry-urgent'; + expiryText = `⏰ Scade fra ${days}gg`; + } else if (days <= 5) { + expiryClass = 'opened-expiry-soon'; + expiryText = `⏰ Scade fra ${days}gg`; + } else { + expiryClass = 'opened-expiry-ok'; + expiryText = `✅ Ancora ${days}gg`; + } + const vacuumNote = item.vacuum_sealed ? ' 🔒' : ''; + expiryBadge = `${expiryText}${vacuumNote}`; + } + return ` -
+
${escapeHtml(item.name)} ${item.brand ? `${escapeHtml(item.brand)}` : ''} @@ -1125,9 +1151,10 @@ async function loadDashboard() {
${locInfo.icon} ${locInfo.label} ${qtyText} + ${expiryBadge}
`; - }).join(''); + }).join('') + (extra > 0 ? `
e altri ${extra} prodotti aperti...
` : ''); } else { openedSection.style.display = 'none'; }