fix: smart shopping family suppression, shelf life pre-warming (v1.7.7)

- Remove recentlyExhausted bypass from shopping_name family suppression:
  products recently exhausted (<14d) were incorrectly flagged as critical
  even when the same family had ample stock (Yogurt 2002g, Affettato 1022g,
  Pane 400g). recentlyExhausted now only bypasses loose token-based coverage.
- Add prewarmShelfLifeCache() in cron: pre-warms opened shelf life via
  Gemini AI (max 5 items/cycle) so the UI never blocks on first load.
This commit is contained in:
dadaloop82
2026-05-10 13:18:41 +00:00
parent ed447d5811
commit 75ca49ac4e
4 changed files with 59 additions and 3 deletions
+6
View File
@@ -5,6 +5,12 @@ All notable changes to EverShelf will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.7] - 2026-05-10
### Fixed
- **Smart shopping family suppression** — La logica `recentlyExhausted` (prodotti terminati < 14gg) bypassava erroneamente anche la suppression per `shopping_name` family, causando falsi positivi: prodotti come Yaourt Vanille apparivano come urgenti anche con 2kg di Yogurt in stock, Salame Paesano con 1kg di Affettato in stock, Gran bauletto rustico con più pani in stock. Ora `recentlyExhausted` bypassa solo il check token-based (match lasco), mentre la family suppression per `shopping_name` si applica sempre.
- **Shelf life pre-warming nel cron** — Il cron ora chiama `prewarmShelfLifeCache()` ogni 5 minuti, precaricando via Gemini AI la shelf life degli item aperti in inventario (max 5 item per ciclo) prima che l'utente li visualizzi. Questo elimina il delay percepibile al primo click su "Aperto il...".
## [1.7.6] - 2026-05-10 ## [1.7.6] - 2026-05-10
### Fixed ### Fixed
+13
View File
@@ -66,6 +66,19 @@ try {
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n"; echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
} }
// ── Shelf life pre-warming ────────────────────────────────────────────
// Pre-warm the opened shelf life cache for opened items not yet cached.
// Capped at 5 items per cron cycle to avoid Gemini rate limits.
try {
$prewarmResult = prewarmShelfLifeCache($db, 5);
if ($prewarmResult['warmed'] > 0) {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm — warmed: ' . $prewarmResult['warmed']
. ', skipped: ' . $prewarmResult['skipped'] . "\n";
}
} catch (Throwable $pe) {
echo '[' . date('Y-m-d H:i:s') . '] Shelf life pre-warm warning: ' . $pe->getMessage() . "\n";
}
} catch (Throwable $e) { } catch (Throwable $e) {
$msg = $e->getMessage(); $msg = $e->getMessage();
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n"; echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
+39 -2
View File
@@ -2386,6 +2386,42 @@ function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 3
// ===== AI-POWERED OPENED SHELF LIFE ===== // ===== AI-POWERED OPENED SHELF LIFE =====
/**
* Cron helper: pre-warm the opened shelf life cache for opened inventory items that
* have no cache entry yet. Called once per cron cycle; capped to $limit items to
* avoid blocking or hitting Gemini rate limits.
* Returns ['warmed' => int, 'skipped' => int].
*/
function prewarmShelfLifeCache(PDO $db, int $limit = 5): array {
$cacheFile = __DIR__ . '/../data/opened_shelf_cache.json';
$cache = [];
if (file_exists($cacheFile)) {
$cache = json_decode(file_get_contents($cacheFile), true) ?: [];
}
// Fetch opened items from inventory (only those still with quantity > 0)
$rows = $db->query("
SELECT p.name, p.category, i.location
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.opened_at IS NOT NULL AND i.quantity > 0
ORDER BY i.opened_at ASC
")->fetchAll(PDO::FETCH_ASSOC);
$warmed = 0;
$skipped = 0;
foreach ($rows as $row) {
if ($warmed >= $limit) { $skipped++; continue; }
$cacheKey = md5(mb_strtolower($row['name']) . '|' . mb_strtolower($row['location']));
if (isset($cache[$cacheKey])) { $skipped++; continue; }
// Call with AI enabled — this writes to cache internally
getOpenedShelfLifeDays($row['name'], $row['category'] ?? '', $row['location'], false, true);
$warmed++;
}
return ['warmed' => $warmed, 'skipped' => $skipped];
}
/** /**
* Return the number of days a product remains safe after opening, depending on storage location. * Return the number of days a product remains safe after opening, depending on storage location.
* Checks a local JSON cache first (keyed by product name+location); on cache miss, asks Gemini AI. * Checks a local JSON cache first (keyed by product name+location); on cache miss, asks Gemini AI.
@@ -5655,8 +5691,9 @@ function smartShopping(PDO $db): void {
// (e.g. "Formaggio") and there's stock of ANY product with the same generic name, // (e.g. "Formaggio") and there's stock of ANY product with the same generic name,
// the need is covered. This catches "Bel Paese" → covered by "Formaggio Gouda" in stock, // the need is covered. This catches "Bel Paese" → covered by "Formaggio Gouda" in stock,
// "Biscotti Pastefrolle" → covered by "Frollini..." (both shopping_name="Biscotti"), etc. // "Biscotti Pastefrolle" → covered by "Frollini..." (both shopping_name="Biscotti"), etc.
// Exception: recently exhausted products (< 14 days) skip this suppression too. // NOTE: recentlyExhausted does NOT bypass this check — same-family stock always suppresses.
if (!$coveredByEquivalent && !$recentlyExhausted) { // recentlyExhausted only bypasses the loose token-based check above.
if (!$coveredByEquivalent) {
$sName = strtolower(trim($p['shopping_name'] ?? '')); $sName = strtolower(trim($p['shopping_name'] ?? ''));
if ($sName !== '' && ($stockByShoppingName[$sName] ?? 0) > 0) { if ($sName !== '' && ($stockByShoppingName[$sName] ?? 0) > 0) {
$coveredByEquivalent = true; $coveredByEquivalent = true;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.6", "version": "1.7.7",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",