chore: auto-merge develop → main

Triggered by: 7eda4a5 Release v1.7.38: stable shopping total and finished-product Bring sync.
This commit is contained in:
github-actions[bot]
2026-06-04 18:12:09 +00:00
7 changed files with 413 additions and 391 deletions
+10
View File
@@ -11,6 +11,16 @@ 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.38] - 2026-06-04
### Fixed
- **Finished products on shopping list** — Depleted items are now added to Bring! under their generic `shopping_name` (e.g. “Affettato”). If the generic is already on the list, the specific variant is appended to the specification instead of being skipped. Confirming a ghost/finished product from the dashboard banner also triggers this flow.
- **Unstable shopping total** — Dashboard, Spesa tab, Home Assistant and screensaver now share one **weekly canonical total** (`PRICE_UPDATE_WEEKS=1`). Totals use **1 package per list item** (no more day-to-day swings from smart-shopping suggested quantities). AI prices are fetched only for items missing from cache; manual 🔄 refresh forces an update.
- **Screensaver price mismatch** — Screensaver waits for the canonical total sync before displaying the amount, matching the other surfaces.
### Changed
- **Shopping list UI** — Generic list entries show the group name with specific finished variants underneath (same pattern as smart shopping suggestions).
## [1.7.37] - 2026-06-04
### Fixed
+1 -1
View File
@@ -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.37-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.38-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)
+344 -344
View File
@@ -1570,66 +1570,49 @@ function haInventorySensor(PDO $db): void {
$daysToNextExpiry = (int)$diff->format('%r%a');
}
// 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.
// Shopping total from canonical weekly cache (same source as UI and screensaver).
$priceEnabled = env('PRICE_ENABLED', 'false') === 'true';
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
$shoppingTotal = null;
if ($priceEnabled) {
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
if (file_exists($totalCachePath)) {
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
$best = null; $bestTs = 0;
foreach ($tc as $entry) {
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
$bestTs = $entry['ts'];
$best = $entry;
$country = env('PRICE_COUNTRY', 'Italia');
$shopNames = [];
if (isShoppingBringMode()) {
$auth = bringAuth();
if ($auth) {
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
foreach ($listData['purchase'] ?? [] as $item) {
$shopNames[] = bringToItalian($item['name'] ?? '');
}
}
if ($best && (time() - $bestTs) < 86400) {
$shoppingTotal = round((float)($best['result']['total'] ?? 0), 2);
} else {
$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);
$seenNames = [];
foreach ($shopRows as $r) {
$sname = $r['sname'] ?? $r['name'];
if (isset($seenNames[$sname])) continue;
$seenNames[$sname] = true;
$shopNames[] = $sname;
}
}
// 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));
}
if (!empty($shopNames)) {
$listHash = _shoppingListHash($shopNames, $country, $priceCurrency);
$cached = _loadCanonicalShoppingTotal($listHash);
if ($cached !== null) {
$shoppingTotal = round((float)($cached['total'] ?? 0), 2);
} else {
$computed = _computeAllShoppingPrices(
array_map(static fn($n) => ['name' => $n], $shopNames),
$country,
$priceCurrency,
'it',
false
);
$shoppingTotal = round((float)($computed['total'] ?? 0), 2);
}
}
}
@@ -1810,15 +1793,15 @@ function haRefreshPrices(PDO $db): void {
try {
$country = env('PRICE_COUNTRY', 'Italia');
$currency = env('PRICE_CURRENCY', 'EUR');
$lang = 'it';
// Get shopping list
$shoppingItems = [];
$clientItems = [];
if (isShoppingBringMode()) {
$auth = bringAuth();
if ($auth) {
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
foreach ($listData['purchase'] ?? [] as $item) {
$shoppingItems[] = ['name' => $item['name'], 'quantity' => 1, 'unit' => 'pz', 'default_quantity' => 0, 'package_unit' => ''];
$clientItems[] = ['name' => bringToItalian($item['name'] ?? '')];
}
}
} else {
@@ -1827,45 +1810,24 @@ function haRefreshPrices(PDO $db): void {
FROM shopping_list sl
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
")->fetchAll(PDO::FETCH_ASSOC);
$seenSnamesHa = [];
$seen = [];
foreach ($rows as $r) {
$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' => ''];
if (isset($seen[$sname])) continue;
$seen[$sname] = true;
$clientItems[] = ['name' => $sname];
}
}
$priceCache = _loadPriceCache();
$total = 0.0;
$priced = 0;
$missing = [];
foreach ($shoppingItems as $item) {
$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++;
} else {
$missing[] = $item['name'];
}
}
$total = round($total, 2);
// Persist to total cache
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
$result = ['success' => true, 'total' => $total, 'total_label' => _formatPrice($total, $currency), 'priced_items' => $priced, 'missing_items' => count($missing)];
$tc = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
$key = 'ha_refresh_' . date('Ymd');
$tc[$key] = ['ts' => time(), 'result' => $result];
if (count($tc) >= 10) $tc = array_slice($tc, -9, null, true);
file_put_contents($totalCachePath, json_encode($tc, JSON_UNESCAPED_UNICODE));
echo json_encode($result, JSON_UNESCAPED_UNICODE);
$result = _computeAllShoppingPrices($clientItems, $country, $currency, $lang, false);
$priced = count(array_filter($result['prices'] ?? [], static fn($e) => !empty($e['price_per_unit'])));
echo json_encode([
'success' => true,
'total' => $result['total'] ?? 0,
'total_label' => $result['total_label'] ?? _formatPrice(0, $currency),
'priced_items' => $priced,
'missing_items' => max(0, count($clientItems) - $priced),
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
@@ -3134,83 +3096,10 @@ function useFromInventory(PDO $db): void {
$stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0");
$stmt->execute([$productId]);
$totalLeft = (float)($stmt->fetchColumn() ?: 0);
if ($totalLeft <= 0) {
// Get product name, brand and shopping_name for Bring!
$stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?");
$stmt->execute([$productId]);
$product = $stmt->fetch();
if ($product) {
// Before adding to Bring!, check if the shopping_name family already
// has adequate stock from OTHER products (e.g. "Sale marino iodato" depleted
// but "Sale alimentare" has 1kg → no need to add to shopping list).
$sNameKey = strtolower(trim($product['shopping_name'] ?? ''));
$familyCoverage = 0;
if ($sNameKey !== '') {
$covStmt = $db->prepare("
SELECT SUM(i.quantity)
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE LOWER(TRIM(p.shopping_name)) = ? AND i.product_id != ? AND i.quantity > 0
");
$covStmt->execute([$sNameKey, $productId]);
$familyCoverage = (float)($covStmt->fetchColumn() ?: 0);
}
if ($familyCoverage > 0) {
// Family has stock — no need to restock, suppress Bring! add.
// Set addedToBring=true so the JS fallback is also suppressed.
$addedToBring = true;
} else {
try {
$auth = bringAuth();
if ($auth) {
$listUUID = $auth['bringListUUID'];
// Use the generic shopping name for Bring! (e.g. "Latte", "Affettato")
$genericName = $product['shopping_name'] ?: computeShoppingName($product['name'], '', $product['brand']);
$bringName = italianToBring($genericName);
// Check if already on the Bring! list
$alreadyOnList = false;
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
if ($listData && isset($listData['purchase'])) {
foreach ($listData['purchase'] as $existingItem) {
if (strcasecmp($existingItem['name'] ?? '', $bringName) === 0) {
$alreadyOnList = true;
break;
}
}
}
if ($alreadyOnList) {
// Already on the list, skip adding
$addedToBring = false;
} else {
// Specification: specific product name (and brand) so the user knows which variant
// Add 🛒 marker so the cron cleanup can auto-remove if no longer needed.
$spec = $genericName !== $product['name']
? $product['name'] . ($product['brand'] ? ' · ' . $product['brand'] : '') . ' · 🛒 Esaurito'
: ($product['brand'] ?: $product['name']) . ' · 🛒 Esaurito';
$body = http_build_query([
'uuid' => $listUUID,
'purchase' => $bringName,
'specification' => $spec,
]);
$result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
$addedToBring = ($result !== null);
// Log Bring! addition
if ($addedToBring) {
$logStmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'bring', 0, '', 'Auto-aggiunto a Bring!')");
$logStmt->execute([$productId]);
}
} // end else (not already on list)
}
} catch (Exception $e) {
// Silently fail — don't block inventory operation
}
} // end else (family not covered)
}
$bringResult = bringAddDepletedProduct($db, $productId);
$addedToBring = !empty($bringResult['added']) || !empty($bringResult['updated']);
}
}
@@ -3611,7 +3500,9 @@ function confirmFinished(PDO $db): void {
}
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")->execute([$productId]);
echo json_encode(['success' => true]);
$bring = bringAddDepletedProduct($db, $productId);
echo json_encode(['success' => true, 'bring' => $bring], JSON_UNESCAPED_UNICODE);
}
/**
@@ -4762,6 +4653,7 @@ function getServerSettings(): void {
'price_country' => env('PRICE_COUNTRY', 'Italia'),
'price_currency' => env('PRICE_CURRENCY', 'EUR'),
'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'),
'price_update_weeks' => (int)env('PRICE_UPDATE_WEEKS', '1'),
'recipe_retention_days' => (int)env('RECIPE_RETENTION_DAYS', '7'),
'transaction_retention_days' => (int)env('TRANSACTION_RETENTION_DAYS', '90'),
'vacuum_expiry_extension_days' => (int)env('VACUUM_EXPIRY_EXTENSION_DAYS', '30'),
@@ -8624,6 +8516,116 @@ function bringGetList(): void {
}
}
/**
* Add or update a depleted product on Bring! under its generic shopping_name.
* If the generic item is already on the list, appends the specific variant to the specification.
*/
function bringAddDepletedProduct(PDO $db, int $productId): array {
$out = ['added' => false, 'updated' => false, 'skipped' => false, 'generic_name' => ''];
$stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?");
$stmt->execute([$productId]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$product) {
$out['skipped'] = true;
return $out;
}
$sNameKey = strtolower(trim($product['shopping_name'] ?? ''));
if ($sNameKey !== '') {
$covStmt = $db->prepare("
SELECT SUM(i.quantity)
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE LOWER(TRIM(p.shopping_name)) = ? AND i.product_id != ? AND i.quantity > 0
");
$covStmt->execute([$sNameKey, $productId]);
if ((float)($covStmt->fetchColumn() ?: 0) > 0) {
$out['skipped'] = true;
return $out;
}
}
$auth = bringAuth();
if (!$auth) {
$out['skipped'] = true;
return $out;
}
$listUUID = $auth['bringListUUID'] ?? '';
if ($listUUID === '') {
$out['skipped'] = true;
return $out;
}
$genericName = $product['shopping_name'] ?: computeShoppingName($product['name'], '', $product['brand'] ?? '');
$out['generic_name'] = $genericName;
$bringName = italianToBring($genericName);
$bringKey = strtolower($bringName);
$specificLine = $genericName !== $product['name']
? $product['name'] . (!empty($product['brand']) ? ' · ' . $product['brand'] : '')
: (!empty($product['brand']) ? $product['brand'] : $product['name']);
$finishedMarker = '🛒 Esaurito';
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
$existingSpec = '';
$alreadyOnList = false;
if ($listData && isset($listData['purchase'])) {
foreach ($listData['purchase'] as $existingItem) {
if (strcasecmp($existingItem['name'] ?? '', $bringName) === 0) {
$alreadyOnList = true;
$existingSpec = $existingItem['specification'] ?? '';
break;
}
}
}
if ($alreadyOnList) {
$newSpec = $existingSpec;
if ($specificLine !== '' && mb_stripos($existingSpec, $specificLine) === false) {
$base = trim(preg_replace('/\s*·\s*🛒\s*Esaurito\s*$/u', '', $existingSpec) ?? $existingSpec);
$newSpec = $base !== ''
? $base . ' · ' . $specificLine . ' · ' . $finishedMarker
: $specificLine . ' · ' . $finishedMarker;
} elseif ($existingSpec === '' || mb_stripos($existingSpec, $finishedMarker) === false) {
$newSpec = trim($existingSpec) !== ''
? trim($existingSpec) . ' · ' . $finishedMarker
: $specificLine . ' · ' . $finishedMarker;
}
if ($newSpec === $existingSpec) {
$out['skipped'] = true;
return $out;
}
$body = http_build_query([
'uuid' => $listUUID,
'purchase' => $bringName,
'specification' => $newSpec,
]);
if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) {
$out['updated'] = true;
@unlink(__DIR__ . '/../data/smart_shopping_cache.json');
}
return $out;
}
$spec = $genericName !== $product['name']
? $specificLine . ' · ' . $finishedMarker
: $specificLine . ' · ' . $finishedMarker;
$body = http_build_query([
'uuid' => $listUUID,
'purchase' => $bringName,
'specification' => $spec,
]);
if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) {
$out['added'] = true;
$logStmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'bring', 0, '', 'Auto-aggiunto a Bring!')");
$logStmt->execute([$productId]);
@unlink(__DIR__ . '/../data/smart_shopping_cache.json');
_fireHaWebhook('shopping_add', ['item' => $genericName, 'specification' => $spec]);
}
return $out;
}
function bringAddItems(): void {
$auth = bringAuth();
if (!$auth) {
@@ -10936,6 +10938,179 @@ function _priceKey(string $name, string $country): string {
return md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country)) . '|v3');
}
/** Max age for cached unit prices and canonical shopping total (default: 1 week). */
function _shoppingPriceMaxAgeSeconds(): int {
$weeks = (int)env('PRICE_UPDATE_WEEKS', '1');
if ($weeks > 0) return $weeks * 7 * 86400;
$months = (int)env('PRICE_UPDATE_MONTHS', '3');
return max(7 * 86400, $months * 30 * 86400);
}
function _shoppingListHash(array $names, string $country, string $currency): string {
$sorted = array_values(array_unique(array_map(
static fn($n) => mb_strtolower(trim((string)$n)),
array_filter($names, static fn($n) => trim((string)$n) !== '')
)));
sort($sorted);
return md5(json_encode($sorted, JSON_UNESCAPED_UNICODE) . '|' . mb_strtolower(trim($country)) . '|' . mb_strtolower(trim($currency)));
}
function _loadCanonicalShoppingTotal(string $listHash): ?array {
$path = __DIR__ . '/../data/shopping_total_cache.json';
if (!file_exists($path)) return null;
$tc = json_decode(file_get_contents($path), true) ?? [];
$entry = $tc['_canonical'] ?? null;
if (!$entry || ($entry['list_hash'] ?? '') !== $listHash) return null;
if (time() - (int)($entry['ts'] ?? 0) >= _shoppingPriceMaxAgeSeconds()) return null;
$result = $entry['result'] ?? null;
return is_array($result) ? $result : null;
}
function _saveCanonicalShoppingTotal(string $listHash, array $result): void {
$path = __DIR__ . '/../data/shopping_total_cache.json';
$tc = file_exists($path) ? (json_decode(file_get_contents($path), true) ?? []) : [];
$tc['_canonical'] = ['ts' => time(), 'list_hash' => $listHash, 'result' => $result];
file_put_contents($path, json_encode($tc, JSON_UNESCAPED_UNICODE));
}
/**
* Stable shopping-list items for price totals: one retail unit per list entry.
* Avoids day-to-day swings from smart-shopping suggested quantities.
*/
function _shoppingListPriceItems(array $clientItems): array {
$items = [];
foreach ($clientItems as $ci) {
$name = trim($ci['name'] ?? '');
if ($name === '') continue;
$items[] = [
'name' => $name,
'quantity' => 1,
'unit' => 'conf',
'default_quantity' => 0,
'package_unit' => '',
];
}
return $items;
}
/**
* Compute shopping list prices + canonical total (shared by UI, HA and screensaver).
*/
function _computeAllShoppingPrices(array $clientItems, string $country, string $currency, string $lang, bool $forceRefresh): array {
$items = _shoppingListPriceItems($clientItems);
if (empty($items)) {
return [
'success' => true,
'prices' => [],
'total' => 0,
'total_label' => _formatPrice(0, $currency),
'from_total_cache' => false,
];
}
$names = array_column($items, 'name');
$listHash = _shoppingListHash($names, $country, $currency);
if (!$forceRefresh) {
$cached = _loadCanonicalShoppingTotal($listHash);
if ($cached !== null) {
$cached['from_total_cache'] = true;
return $cached;
}
}
$priceCache = _loadPriceCache();
$now = time();
$maxAge = _shoppingPriceMaxAgeSeconds();
$prices = [];
$total = 0.0;
$missing = [];
foreach ($items as $item) {
$name = $item['name'];
$key = _priceKey($name, $country);
$key0 = md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country)));
$entry = $priceCache[$key] ?? $priceCache[$key0] ?? null;
if ($entry !== null && !$forceRefresh) {
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => true,
'_resolved_qty' => $item['quantity'],
'_resolved_unit' => $item['unit'],
]);
$total += $est ?? 0;
continue;
}
if ($entry !== null && $forceRefresh && ($now - (int)($entry['cached_at'] ?? 0)) < $maxAge) {
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => true,
'_resolved_qty' => $item['quantity'],
'_resolved_unit' => $item['unit'],
]);
$total += $est ?? 0;
continue;
}
if ($entry === null || $forceRefresh) {
$missing[] = $item;
}
}
if (!empty($missing)) {
$missingNames = array_column($missing, 'name');
$batchPrices = _fetchPricesBatchFromAI($missingNames, $country, $currency, $lang);
$missingByName = [];
foreach ($missing as $item) $missingByName[$item['name']] = $item;
foreach ($missingNames as $name) {
$item = $missingByName[$name];
$key = _priceKey($name, $country);
$priceData = $batchPrices[$name] ?? null;
if ($priceData && isset($priceData['price_per_unit'])) {
$entry = [
'name' => $name,
'price_per_unit' => (float)$priceData['price_per_unit'],
'unit_label' => $priceData['unit_label'] ?? 'pz',
'currency' => $currency,
'source_note' => $priceData['source_note'] ?? '',
'country' => $country,
'cached_at' => $now,
];
$priceCache[$key] = $entry;
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => false,
'_resolved_qty' => $item['quantity'],
'_resolved_unit' => $item['unit'],
]);
$total += $est ?? 0;
} else {
$prices[$name] = ['name' => $name, 'error' => 'not_found', 'estimated_total' => null];
}
}
_savePriceCache($priceCache);
}
$total = round($total, 2);
$result = [
'success' => true,
'prices' => $prices,
'total' => $total,
'total_label' => _formatPrice($total, $currency),
'from_total_cache' => false,
'priced_at' => $now,
'valid_until' => $now + $maxAge,
];
_saveCanonicalShoppingTotal($listHash, $result);
return $result;
}
/**
* Ask Gemini for the estimated retail price per unit (kg, l, pz as appropriate)
* for a product in a given country/currency. Returns an array:
@@ -11082,7 +11257,7 @@ function getShoppingPrice(PDO $db): void {
$currency= trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR'));
$lang = trim($input['lang'] ?? 'it');
$forceRefresh = !empty($input['force_refresh']);
$updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3');
$maxAge = _shoppingPriceMaxAgeSeconds();
if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'missing name']);
@@ -11098,7 +11273,6 @@ function getShoppingPrice(PDO $db): void {
$cache = _loadPriceCache();
$key = _priceKey($name, $country);
$now = time();
$maxAge = $updateMonths * 30 * 86400;
// Use cache if fresh
if (!$forceRefresh && isset($cache[$key])) {
@@ -11148,7 +11322,6 @@ function getShoppingPrice(PDO $db): void {
*/
function getAllShoppingPrices(PDO $db): void {
EverLog::info('getAllShoppingPrices');
// This endpoint may call the AI for many items at once — extend timeout.
set_time_limit(120);
$input = json_decode(file_get_contents('php://input'), true) ?? [];
@@ -11156,182 +11329,9 @@ function getAllShoppingPrices(PDO $db): void {
$country = trim($input['country'] ?? env('PRICE_COUNTRY', 'Italia'));
$currency = trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR'));
$lang = trim($input['lang'] ?? 'it');
$forceRefresh = !empty($input['force_refresh']); // re-fetch AI prices (expensive, rarely used)
$forceTotal = !empty($input['force_total']); // bust only the 5-min total cache (fast)
$updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3');
if (empty($clientItems)) {
echo json_encode(['success' => true, 'prices' => [], 'total' => 0, 'total_label' => _formatPrice(0, $currency)]);
return;
}
// ── Resolve qty/unit from server-side smart cache (source of truth) ──────
$smartItems = [];
$smartCacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
if (file_exists($smartCacheFile)) {
$raw = file_get_contents($smartCacheFile);
if ($raw) {
$sc = json_decode($raw, true);
if ($sc && isset($sc['items'])) $smartItems = $sc['items'];
}
}
// Build lookup: lowercase name/shopping_name → smart item
$smartByName = [];
foreach ($smartItems as $si) {
$smartByName[mb_strtolower($si['name'] ?? '')] = $si;
if (!empty($si['shopping_name'])) {
$smartByName[mb_strtolower($si['shopping_name'])] = $si;
}
}
// Build canonical items array using server-side qty/unit
$items = [];
foreach ($clientItems as $ci) {
$name = trim($ci['name'] ?? '');
if ($name === '') continue;
// 1) Exact match by name or shopping_name
$si = $smartByName[mb_strtolower($name)] ?? null;
// 2) Prefix-word fallback: "Salame" → "Salame Paesano", "Penne" → "Penne rigate"
// Match when the Bring! name is a word-prefix of a smart key (case-insensitive).
if ($si === null) {
$nameLower = mb_strtolower($name);
foreach ($smartByName as $smartKey => $candidate) {
// smartKey starts with the Bring! name (exact word boundary)
if (str_starts_with($smartKey, $nameLower)
&& (strlen($smartKey) === strlen($nameLower) || $smartKey[strlen($nameLower)] === ' ')) {
$si = $candidate;
break;
}
}
}
$items[] = [
'name' => $name,
'quantity' => (float)(($si['suggested_qty'] ?? $si['buy_qty'] ?? null) ?? ($ci['quantity'] ?? 1)),
'unit' => trim(($si['suggested_unit'] ?? $si['unit'] ?? null) ?? ($ci['unit'] ?? 'conf')),
'default_quantity' => (float)(($si['default_qty'] ?? null) ?? ($ci['default_quantity'] ?? 0)),
'package_unit' => trim(($si['package_unit'] ?? null) ?? ($ci['package_unit'] ?? '')),
];
}
// ── 5-minute server-side total cache ──────────────────────────────────────
// Key = hash of item names + resolved qty/unit + country (not force_refresh)
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
$totalCacheKey = md5(json_encode(array_map(
fn($i) => [$i['name'], $i['quantity'], $i['unit']],
$items
)) . $country . $currency);
if (!$forceRefresh && !$forceTotal && file_exists($totalCachePath)) {
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
if (isset($tc[$totalCacheKey]) && (time() - ($tc[$totalCacheKey]['ts'] ?? 0)) < 300) {
$cached = $tc[$totalCacheKey]['result'];
$cached['from_total_cache'] = true;
echo json_encode($cached, JSON_UNESCAPED_UNICODE);
return;
}
}
// ── Price computation ─────────────────────────────────────────────────────
$priceCache = _loadPriceCache();
$now = time();
$maxAge = $updateMonths * 30 * 86400;
$prices = [];
$total = 0.0;
$missing = [];
// First pass: serve from cache
foreach ($items as $item) {
$name = $item['name'];
$qty = $item['quantity'];
$unit = $item['unit'];
$defQty = $item['default_quantity'];
$pkgUnit = $item['package_unit'];
$key = _priceKey($name, $country);
if (!$forceRefresh && isset($priceCache[$key])) {
$age = $now - ($priceCache[$key]['cached_at'] ?? 0);
if ($age < $maxAge) {
$entry = $priceCache[$key];
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $qty, $unit, $defQty, $pkgUnit);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => true,
'_resolved_qty' => $qty,
'_resolved_unit' => $unit,
]);
$total += $est ?? 0;
continue;
}
}
$missing[] = $item;
}
// Second pass: fetch ALL missing items in ONE batch Gemini call
if (!empty($missing)) {
$missingNames = array_column($missing, 'name');
$batchPrices = _fetchPricesBatchFromAI($missingNames, $country, $currency, $lang);
// Build a lookup from item name → item params
$missingByName = [];
foreach ($missing as $item) $missingByName[$item['name']] = $item;
foreach ($missingNames as $name) {
$item = $missingByName[$name];
$qty = $item['quantity'];
$unit = $item['unit'];
$defQty = $item['default_quantity'];
$pkgUnit = $item['package_unit'];
$key = _priceKey($name, $country);
$priceData = $batchPrices[$name] ?? null;
if ($priceData && isset($priceData['price_per_unit'])) {
$entry = [
'name' => $name,
'price_per_unit' => (float)$priceData['price_per_unit'],
'unit_label' => $priceData['unit_label'] ?? 'pz',
'currency' => $currency,
'source_note' => $priceData['source_note'] ?? '',
'country' => $country,
'cached_at' => $now,
];
$priceCache[$key] = $entry;
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $qty, $unit, $defQty, $pkgUnit);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => false,
'_resolved_qty' => $qty,
'_resolved_unit' => $unit,
]);
$total += $est ?? 0;
} else {
$prices[$name] = ['name' => $name, 'error' => 'not_found', 'estimated_total' => null];
}
}
}
_savePriceCache($priceCache);
$total = round($total, 2);
$result = [
'success' => true,
'prices' => $prices,
'total' => $total,
'total_label' => _formatPrice($total, $currency),
'from_total_cache' => false,
];
// Persist to total cache
$tc = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
// Keep cache small: max 10 keys (different list configurations)
if (count($tc) >= 10) $tc = array_slice($tc, -9, null, true);
$tc[$totalCacheKey] = ['ts' => $now, 'result' => $result];
file_put_contents($totalCachePath, json_encode($tc, JSON_UNESCAPED_UNICODE));
$forceRefresh = !empty($input['force_refresh']);
$result = _computeAllShoppingPrices($clientItems, $country, $currency, $lang, $forceRefresh);
echo json_encode($result, JSON_UNESCAPED_UNICODE);
}
+8
View File
@@ -3098,6 +3098,14 @@ body.server-offline .bottom-nav {
font-style: italic;
}
.shopping-item-specific {
font-size: 0.73rem;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.3;
font-style: italic;
}
.smart-brand {
font-weight: 400;
color: var(--text-muted);
+46 -42
View File
@@ -5980,7 +5980,11 @@ async function confirmBannerFinished() {
if (!entry || entry.type !== 'finished') return;
const productId = entry.data.product_id;
try {
await api('inventory_confirm_finished', {}, 'POST', { product_id: productId });
const res = await api('inventory_confirm_finished', {}, 'POST', { product_id: productId });
if (res.bring?.added || res.bring?.updated) {
showToast(t('toast.finished_to_bring'), 'info');
loadShoppingList();
}
} catch(e) {}
showToast(t('toast.product_finished_confirmed'), 'success');
dismissBannerItem();
@@ -9868,14 +9872,18 @@ function _findSimilarItem(name, list) {
*/
function _matchBringToSmart(bringName, smartItems) {
const bLower = bringName.toLowerCase();
const exact = smartItems.find(sd => sd.name.toLowerCase() === bLower);
const exact = smartItems.find(sd =>
sd.name.toLowerCase() === bLower ||
(sd.shopping_name || '').toLowerCase() === bLower
);
if (exact) return exact;
const bTokens = _nameTokens(bringName);
if (bTokens.length === 0) return null;
const bFirst = bTokens[0];
// Rule 2: first token match
const firstMatch = smartItems.find(sd => {
const sdTokens = _nameTokens(sd.name);
const groupName = (sd.shopping_name || sd.name).toLowerCase();
if (groupName === bLower) return true;
const sdTokens = _nameTokens(sd.shopping_name || sd.name);
return sdTokens.length > 0 && sdTokens[0] === bFirst;
});
if (firstMatch) return firstMatch;
@@ -11443,37 +11451,14 @@ async function syncShoppingPriceTotal(forceRefresh = false) {
* Tries to parse quantity/unit from the Bring! specification field.
*/
function _buildPricePayload() {
return shoppingItems.map((item) => {
// Look up the matching smart shopping item to get reliable qty/unit data.
// Bring! spec strings can be stale or free-text — don't trust them for calculations.
const nameLower = item.name.toLowerCase();
const smart = (smartShoppingItems || []).find(s =>
s.name.toLowerCase() === nameLower ||
(s.shopping_name || '').toLowerCase() === nameLower
);
let quantity = smart?.suggested_qty || 1;
let unit = smart?.suggested_unit || smart?.unit || 'pz';
let default_quantity = smart?.default_qty || 0;
let package_unit = smart?.package_unit || '';
// If no smart match, fall back to parsing the Bring! spec (last resort)
if (!smart) {
const spec = item.specification || '';
const qtyMatch = spec.match(/(\d+(?:[.,]\d+)?)\s*(g|kg|ml|l|pz|conf|lt|liter|litre)\b/i);
if (qtyMatch) {
quantity = parseFloat(qtyMatch[1].replace(',', '.'));
unit = qtyMatch[2].toLowerCase();
} else {
// Manually-added item with no spec: assume 1 confezione
// (most grocery items are bought as a single pack)
quantity = 1;
unit = 'conf';
}
}
return { name: item.name, quantity, unit, default_quantity, package_unit };
});
// One retail unit per list item — stable weekly total (server uses the same rule).
return shoppingItems.map((item) => ({
name: item.name,
quantity: 1,
unit: 'conf',
default_quantity: 0,
package_unit: '',
}));
}
/**
@@ -11618,10 +11603,7 @@ async function fetchAllPrices(forceRefresh = false) {
const data = await api('get_all_shopping_prices', {}, 'POST', {
items: itemsPayload,
country, currency, lang,
// force_refresh=true only busts the 5-min total cache on the server;
// it never re-fetches AI prices (3-month per-item cache stays intact)
force_total: forceRefresh,
force_refresh: false,
force_refresh: forceRefresh,
});
if (data && data.success) {
@@ -12565,6 +12547,27 @@ async function renderShoppingItems() {
const bgStyle = urgency && URGENCY_BG[urgency] ? ` style="background:${URGENCY_BG[urgency]}"` : '';
const localTags = getShoppingTags(item.name);
const shoppingName = smartData?.shopping_name || item.name;
const isGenericGroup = smartData && shoppingName.toLowerCase() === item.name.toLowerCase()
&& (smartData.name !== shoppingName || (smartData.variants || []).length > 0);
const displayName = isGenericGroup ? shoppingName : item.name;
let specificLineHtml = '';
if (isGenericGroup) {
const specText = _specDisplayText(item.specification);
let specifics = [];
if (specText) {
specifics.push(specText);
} else {
specifics.push(smartData.name + (smartData.brand ? ` (${smartData.brand})` : ''));
for (const v of (smartData.variants || [])) {
specifics.push(v.name + (v.brand ? ` (${v.brand})` : ''));
}
}
if (specifics.length) {
specificLineHtml = `<div class="shopping-item-specific">${escapeHtml(specifics.join(' · '))}</div>`;
}
}
// Urgency badge
let urgencyBadge = '';
if (urgency && urgencyMap[urgency]) {
@@ -12597,10 +12600,11 @@ async function renderShoppingItems() {
<div class="shopping-item-top">
<div class="shopping-item-info">
<div class="shopping-item-name-row">
<span class="shopping-item-name">${escapeHtml(item.name)}</span>
<span class="shopping-item-name">${escapeHtml(displayName)}</span>
<span class="shopping-item-scan-hint">📷</span>
</div>
${_specDisplayText(item.specification) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''}
${specificLineHtml}
${(!isGenericGroup && _specDisplayText(item.specification)) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''}
${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''}
</div>
${priceEnabled ? `<div class="shopping-item-price-col" id="price-badge-${idx}"><span class="price-col-loading">…</span></div>` : ''}
@@ -16887,7 +16891,7 @@ function activateScreensaver() {
updateScreensaverClock();
_screensaverClockInterval = setInterval(updateScreensaverClock, 1000);
updateScreensaverShopping();
syncShoppingPriceTotal(false);
syncShoppingPriceTotal(false).then(() => updateScreensaverShopping());
// Load data and start fact/nutrition rotation
loadScreensaverData().then(() => {
_startScreensaverRotation();
+3 -3
View File
@@ -72,7 +72,7 @@
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
<span class="app-preloader-version" id="preloader-version">v1.7.37</span>
<span class="app-preloader-version" id="preloader-version">v1.7.38</span>
</div>
</div>
@@ -85,7 +85,7 @@
<!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.37</span>
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.38</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -1970,6 +1970,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260604e"></script>
<script src="assets/js/app.js?v=20260604f"></script>
</body>
</html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf",
"short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.37",
"version": "1.7.38",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",