diff --git a/CHANGELOG.md b/CHANGELOG.md index a06df13..d7aec84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ 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 diff --git a/README.md b/README.md index d0e0bbc..1665701 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ [![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile) [![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/) -[![Version](https://img.shields.io/badge/version-1.7.31-brightgreen.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.33-brightgreen.svg)](CHANGELOG.md) [![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers) [![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main) [![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors) diff --git a/api/index.php b/api/index.php index e80aae0..f28a7c4 100644 --- a/api/index.php +++ b/api/index.php @@ -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++;