Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dea1223faf | |||
| 7eda4a5eb9 | |||
| e72e57edf6 | |||
| b63deca795 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+558
-888
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user