Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf |
@@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.32] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
|
||||
|
||||
|
||||
## [1.7.31] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
|
||||
|
||||
|
||||
## [1.7.30] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
|
||||
+41
-15
@@ -2634,19 +2634,26 @@ function addToInventory(PDO $db): void {
|
||||
|
||||
$vacuumSealed = (int)($input['vacuum_sealed'] ?? 0);
|
||||
|
||||
// Check if product already exists in this location
|
||||
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?");
|
||||
// Check if a SEALED (not yet opened) row exists for this product+location.
|
||||
// We merge new stock into a sealed row only — never into an already-opened
|
||||
// pack, because that would conflate two physically distinct containers and
|
||||
// corrupt the opened_at timestamp tracking.
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, quantity FROM inventory
|
||||
WHERE product_id = ? AND location = ? AND opened_at IS NULL
|
||||
ORDER BY added_at ASC LIMIT 1
|
||||
");
|
||||
$stmt->execute([$productId, $location]);
|
||||
$existing = $stmt->fetch();
|
||||
|
||||
if ($existing) {
|
||||
// Update quantity
|
||||
// Merge into the existing sealed row
|
||||
$newQty = $existing['quantity'] + $quantity;
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]);
|
||||
} else {
|
||||
$newQty = $quantity;
|
||||
// Insert new inventory entry
|
||||
// All existing rows (if any) are opened packs — insert a new sealed row
|
||||
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]);
|
||||
}
|
||||
@@ -8861,7 +8868,9 @@ function smartShopping(PDO $db): void {
|
||||
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
|
||||
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
|
||||
$isExpired = $daysToExpiry < 0;
|
||||
$isExpiringSoon = !$isExpired && $daysToExpiry <= 3;
|
||||
// 7-day warning window: enough to plan the next shopping trip.
|
||||
// The tighter 3-day threshold was often too late for staple products.
|
||||
$isExpiringSoon = !$isExpired && $daysToExpiry <= 7;
|
||||
|
||||
// Fresh (non-expired) quantity — used for suppression when only part of stock is expired
|
||||
$freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0;
|
||||
@@ -8994,31 +9003,48 @@ function smartShopping(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Expiring soon or expired (needs replacement) — valid regardless of frequency
|
||||
// Expiring soon or expired (needs replacement)
|
||||
if ($isExpired && $qty > 0) {
|
||||
// Check if the product's shopping_name FAMILY has adequate FRESH stock
|
||||
// from other (non-expired) products. If so, no need to buy more.
|
||||
$sNameKey = strtolower(trim($p['shopping_name'] ?? ''));
|
||||
$familyFreshQty = $sNameKey !== '' ? ($freshStockByShoppingName[$sNameKey] ?? 0) : 0;
|
||||
// Subtract this product's own qty (it is expired, so fresh_qty=0 for it anyway)
|
||||
$refQtyLocal = $refQty > 0 ? $refQty : 1;
|
||||
$familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100);
|
||||
|
||||
if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) {
|
||||
// Fresh stock from this product or same-family products is adequate.
|
||||
// The expired batch will show in the dashboard expiry banner — don't add to shopping list.
|
||||
} else {
|
||||
} elseif ($isRegular || $buyCount >= 2) {
|
||||
// Only suggest restocking if this is a product the user buys regularly.
|
||||
// If it expired without ever being a staple, the expiry banner is enough.
|
||||
$urgency = 'critical';
|
||||
$reasons[] = 'Scaduto!';
|
||||
$score += 90;
|
||||
}
|
||||
} elseif ($isExpiringSoon && $qty > 0 && $pctLeft < 50) {
|
||||
// Only flag "expiring soon" if stock is also low (<50%). If you have plenty of
|
||||
// stock (e.g. just bought fresh produce that naturally expires in 3 days), the
|
||||
// shopping list is not the right place — the expiry banner handles it.
|
||||
if ($urgency === 'none') $urgency = 'medium';
|
||||
$reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg';
|
||||
$score += 40;
|
||||
// else: one-off product expired unused → expiry banner handles it, no shopping noise
|
||||
} elseif ($isExpiringSoon && $qty > 0) {
|
||||
// Flag if:
|
||||
// (a) regular consumer + stock low (<50%) → needs restock soon
|
||||
// (b) regular consumer + will expire before finishing it
|
||||
// (daysLeft based on consumption rate > days to expiry)
|
||||
// (c) non-regular + within 3 days + low stock → minimal safety net
|
||||
$willExpireBeforeUsed = $dailyRate > 0 && $daysToExpiry < $daysLeft;
|
||||
if ($isRegular && ($pctLeft < 50 || $willExpireBeforeUsed)) {
|
||||
if ($urgency === 'none') $urgency = 'medium';
|
||||
if ($willExpireBeforeUsed && $pctLeft >= 50) {
|
||||
// Has stock but won't finish it in time → buy fresh and use this one now
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg — ricompra';
|
||||
} else {
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg';
|
||||
}
|
||||
$score += 40;
|
||||
} elseif (!$isRegular && $daysToExpiry <= 3 && $pctLeft < 50) {
|
||||
// Non-regular product: only flag when very close and running low
|
||||
if ($urgency === 'none') $urgency = 'low';
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg';
|
||||
$score += 20;
|
||||
}
|
||||
}
|
||||
|
||||
// Frequently used but stock getting low (predictive) — scale urgency by imminence
|
||||
|
||||
Reference in New Issue
Block a user