Compare commits

...

4 Commits

Author SHA1 Message Date
github-actions[bot] dea1223faf chore: auto-merge develop → main
Triggered by: 7eda4a5 Release v1.7.38: stable shopping total and finished-product Bring sync.
2026-06-04 18:12:09 +00:00
dadaloop82 7eda4a5eb9 Release v1.7.38: stable shopping total and finished-product Bring sync.
Add depleted products under generic shopping names, unify weekly canonical price total across all surfaces, and fix screensaver amount mismatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 18:10:24 +00:00
github-actions[bot] e72e57edf6 chore: auto-merge develop → main
Triggered by: b63deca Release v1.7.37: strict recipe pantry matching and renderRecipe fix.
2026-06-04 17:39:57 +00:00
dadaloop82 b63deca795 Release v1.7.37: strict recipe pantry matching and renderRecipe fix.
Prevent false  pantry links via strict name matching and full inventory prompts; fix qtyNum crash when reopening archived recipes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:38:12 +00:00
8 changed files with 659 additions and 938 deletions
+19
View File
@@ -11,6 +11,25 @@ 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
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
### Changed
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
## [1.7.36] - 2026-06-04
### Added
+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.36-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)
+558 -888
View File
File diff suppressed because it is too large Load Diff
+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);
+54 -44
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>` : ''}
@@ -13840,7 +13844,13 @@ async function enrichRecipeIngredientsStock(recipe) {
if (!ing.from_pantry || !ing.product_id) continue;
const rows = inv.filter(i => i.product_id == ing.product_id);
const activeRows = rows.filter(i => parseFloat(i.quantity) > 0);
if (!activeRows.length) continue;
if (!activeRows.length) {
ing.from_pantry = false;
delete ing.product_id;
delete ing.stock_have;
delete ing.stock_remain;
continue;
}
const totalStock = activeRows.reduce((s, i) => s + parseFloat(i.quantity), 0);
ing.inventory_qty_total = totalStock;
const opened = activeRows.find(_isOpenedInventoryItem);
@@ -14388,9 +14398,9 @@ async function renderRecipe(r) {
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
(r.ingredients || []).forEach((ing, idx) => {
if (ing.from_pantry && ing.product_id) {
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const alreadyUsed = ing.used === true;
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}" data-ing-idx="${idx}" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}">`;
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${escapeHtml(t('btn.edit'))}">${escapeHtml(ing.name)}</strong>${ing.brand ? ' <em>(' + escapeHtml(ing.brand) + ')</em>' : ''}: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span>${ing.use_all_suggested ? ' ♻️' : ''}`;
// Detail line: location + expiry
@@ -16881,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.36</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.36</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=20260604c"></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.36",
"version": "1.7.38",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+15 -1
View File
@@ -28,6 +28,17 @@ if (!is_array($recipe)) {
exit(1);
}
$stmt = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
ORDER BY days_left ASC, p.name ASC
");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
recipeApplyStockHintsToRecipe($db, $recipe);
$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?');
@@ -35,7 +46,10 @@ $upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]);
echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n";
foreach ($recipe['ingredients'] ?? [] as $ing) {
if (empty($ing['from_pantry'])) continue;
if (empty($ing['from_pantry'])) {
echo sprintf(" 🛒 %s — %s (da comprare)\n", $ing['name'] ?? '?', $ing['qty'] ?? '?');
continue;
}
$useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : '';
echo sprintf(
" %s: %s | hai %.1f %s | restano %.1f %s%s\n",