Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 |
@@ -11,6 +11,19 @@ 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.33] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
|
||||
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+90
-21
@@ -1570,7 +1570,9 @@ function haInventorySensor(PDO $db): void {
|
||||
$daysToNextExpiry = (int)$diff->format('%r%a');
|
||||
}
|
||||
|
||||
// Shopping total from server-side total cache (max 1 hour old)
|
||||
// Shopping total from server-side total cache (max 24 hours old).
|
||||
// The cache is populated by the JS frontend or by the ha_refresh_prices action.
|
||||
// 24h TTL is sufficient: the total changes slowly and HA polls frequently.
|
||||
$priceEnabled = env('PRICE_ENABLED', 'false') === 'true';
|
||||
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
|
||||
$shoppingTotal = null;
|
||||
@@ -1578,19 +1580,58 @@ function haInventorySensor(PDO $db): void {
|
||||
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
|
||||
if (file_exists($totalCachePath)) {
|
||||
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
|
||||
// Find the most recent entry not older than 1 hour
|
||||
$best = null;
|
||||
$bestTs = 0;
|
||||
$best = null; $bestTs = 0;
|
||||
foreach ($tc as $entry) {
|
||||
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
|
||||
$bestTs = $entry['ts'];
|
||||
$best = $entry;
|
||||
}
|
||||
}
|
||||
if ($best && (time() - $bestTs) < 3600) {
|
||||
if ($best && (time() - $bestTs) < 86400) {
|
||||
$shoppingTotal = round((float)($best['result']['total'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
// If cache is absent or older than 24h, compute inline from the existing
|
||||
// per-item price cache (no AI calls — fast, uses already-stored prices).
|
||||
if ($shoppingTotal === null) {
|
||||
$country = env('PRICE_COUNTRY', 'Italia');
|
||||
$priceCache = _loadPriceCache();
|
||||
if (!empty($priceCache)) {
|
||||
$shopRows = $db->query("
|
||||
SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname
|
||||
FROM shopping_list sl
|
||||
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$inlineTotal = 0.0;
|
||||
$inlinePriced = 0;
|
||||
$seenNames = [];
|
||||
foreach ($shopRows as $r) {
|
||||
$sname = $r['sname'] ?? $r['name'];
|
||||
if (isset($seenNames[$sname])) continue; // deduplicate
|
||||
$seenNames[$sname] = true;
|
||||
$pk = _priceKey($sname, $country);
|
||||
$pk0 = md5(mb_strtolower(trim($sname)) . '|' . mb_strtolower(trim($country)));
|
||||
$e = $priceCache[$pk] ?? $priceCache[$pk0] ?? null;
|
||||
if ($e !== null) {
|
||||
$est = _calcEstimatedTotal(
|
||||
$e['price_per_unit'], $e['unit_label'] ?? '',
|
||||
1, 'pz', 0, ''
|
||||
);
|
||||
$inlineTotal += $est ?? 0;
|
||||
$inlinePriced++;
|
||||
}
|
||||
}
|
||||
if ($inlinePriced > 0) {
|
||||
$shoppingTotal = round($inlineTotal, 2);
|
||||
// Persist so next call is instant
|
||||
$tc2 = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
|
||||
$ckey2 = 'ha_inline_' . date('Ymd_His');
|
||||
$tc2[$ckey2] = ['ts' => time(), 'result' => ['total' => $shoppingTotal, 'priced_items' => $inlinePriced]];
|
||||
if (count($tc2) >= 10) $tc2 = array_slice($tc2, -9, null, true);
|
||||
file_put_contents($totalCachePath, json_encode($tc2, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stateValue = match($sensor) {
|
||||
@@ -1786,9 +1827,17 @@ function haRefreshPrices(PDO $db): void {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rows = $db->query("SELECT name, quantity, unit FROM shopping_list WHERE checked = 0")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$rows = $db->query("
|
||||
SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname
|
||||
FROM shopping_list sl
|
||||
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$seenSnamesHa = [];
|
||||
foreach ($rows as $r) {
|
||||
$shoppingItems[] = ['name' => $r['name'], 'quantity' => (float)($r['quantity'] ?? 1), 'unit' => $r['unit'] ?? 'pz', 'default_quantity' => 0, 'package_unit' => ''];
|
||||
$sname = $r['sname'] ?? $r['name'];
|
||||
if (isset($seenSnamesHa[$sname])) continue;
|
||||
$seenSnamesHa[$sname] = true;
|
||||
$shoppingItems[] = ['name' => $sname, 'quantity' => 1, 'unit' => 'pz', 'default_quantity' => 0, 'package_unit' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1798,9 +1847,10 @@ function haRefreshPrices(PDO $db): void {
|
||||
$missing = [];
|
||||
|
||||
foreach ($shoppingItems as $item) {
|
||||
$key = _priceKey($item['name'], $country);
|
||||
if (isset($priceCache[$key])) {
|
||||
$entry = $priceCache[$key];
|
||||
$key = _priceKey($item['name'], $country);
|
||||
$key0 = md5(mb_strtolower(trim($item['name'])) . '|' . mb_strtolower(trim($country)));
|
||||
$entry = $priceCache[$key] ?? $priceCache[$key0] ?? null;
|
||||
if ($entry !== null) {
|
||||
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
|
||||
$total += $est ?? 0;
|
||||
$priced++;
|
||||
@@ -8868,7 +8918,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;
|
||||
@@ -9001,31 +9053,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