Release v1.7.40: recipe fixes, DB lock retry, and Docker Traefik support.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,26 @@ 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.40] - 2026-06-08
|
||||
|
||||
### Added
|
||||
- **Qty unit badges** — Quantity inputs show the active unit (g, ml, conf, pz, …) on use, add, recipe-use, edit and throw modals; scale live label “Inserimento in …”.
|
||||
- **Recipe shopping suggestions** — AI recipes can list optional missing ingredients with one-tap add to Bring!/shopping list.
|
||||
- **Recipe frozen badge** — Freezer items flagged in pantry lines and recipe UI; prompt rule for cooking from frozen.
|
||||
- **Health check `db_writable`** — Startup diagnostic detects non-writable SQLite file (common Docker volume issue).
|
||||
- **`scripts/triage-open-issues.php`** — Maintenance helper to comment/close GitHub issues via encrypted token.
|
||||
|
||||
### Fixed
|
||||
- **SQLite database locked** — `PRAGMA busy_timeout` 10s + `dbWithRetry()` on `inventory_update` under cron/PWA contention.
|
||||
- **Barcode duplicate on save** — `saveProduct` merges or returns 409 instead of HTTP 500 on UNIQUE barcode.
|
||||
- **EverLog CLI crash** — Safe cast of `REQUEST_METHOD` when null (kiosk/cron).
|
||||
- **Spesa scan crash** — `currentPage` → `_currentPageId` in `_applySpesaScanUI`.
|
||||
- **Recipe quantities** — Piece products use 1 pc base; serving caps for onions, leafy greens, minestrone; pantry-only post-processing; conf/g display fixes.
|
||||
- **Smart shopping purchased block** — Server-side blocklist + spesa mode sync prevents cron from re-adding bought items.
|
||||
|
||||
### Changed
|
||||
- **Docker behind Traefik** — Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` to avoid redirect loops.
|
||||
|
||||
## [1.7.39] - 2026-06-06
|
||||
|
||||
### Added
|
||||
|
||||
+3
-1
@@ -33,7 +33,9 @@ RUN [ ! -f /var/www/html/.env ] && cp /var/www/html/.env.example /var/www/html/.
|
||||
RUN echo '<Directory /var/www/html>\n\
|
||||
AllowOverride All\n\
|
||||
Require all granted\n\
|
||||
</Directory>' > /etc/apache2/conf-available/evershelf.conf \
|
||||
</Directory>\n\
|
||||
# Traefik / reverse-proxy: treat forwarded HTTPS as on so .htaccess does not redirect-loop\n\
|
||||
SetEnvIf X-Forwarded-Proto "https" HTTPS=on' > /etc/apache2/conf-available/evershelf.conf \
|
||||
&& a2enconf evershelf
|
||||
|
||||
# Expose port 80
|
||||
|
||||
@@ -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)
|
||||
|
||||
+40
-1
@@ -38,8 +38,24 @@ function _ensureDataDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure the SQLite DB and WAL sidecar files are writable (Docker volume first-boot). */
|
||||
function _ensureDbWritable(): void {
|
||||
if (!file_exists(DB_PATH)) {
|
||||
return;
|
||||
}
|
||||
if (!is_writable(DB_PATH)) {
|
||||
@chmod(DB_PATH, 0664);
|
||||
}
|
||||
foreach ([DB_PATH . '-wal', DB_PATH . '-shm'] as $sidecar) {
|
||||
if (file_exists($sidecar) && !is_writable($sidecar)) {
|
||||
@chmod($sidecar, 0664);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDB(): PDO {
|
||||
_ensureDataDir();
|
||||
_ensureDbWritable();
|
||||
// logger.php is required by index.php before getDB() is called.
|
||||
// In cron context it may not be loaded yet — guard with class_exists.
|
||||
$useLogging = class_exists('LoggingPDO', false);
|
||||
@@ -53,7 +69,7 @@ function getDB(): PDO {
|
||||
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
|
||||
// For SQLite, we use PRAGMA busy_timeout.
|
||||
$db->exec('PRAGMA journal_mode = WAL;');
|
||||
$db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds
|
||||
$db->exec('PRAGMA busy_timeout = 10000;'); // 10 s — cron + PWA writes can contend under WAL
|
||||
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec("PRAGMA journal_mode=WAL");
|
||||
@@ -72,6 +88,29 @@ function getDB(): PDO {
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a DB write when SQLite returns "database is locked" (concurrent cron + API).
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $fn
|
||||
* @return T
|
||||
*/
|
||||
function dbWithRetry(callable $fn, int $maxAttempts = 4): mixed {
|
||||
$attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return $fn();
|
||||
} catch (\PDOException $e) {
|
||||
$attempt++;
|
||||
$locked = str_contains($e->getMessage(), 'database is locked');
|
||||
if (!$locked || $attempt >= $maxAttempts) {
|
||||
throw $e;
|
||||
}
|
||||
usleep(150000 * $attempt); // 150 ms, 300 ms, 450 ms …
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initializeDB(PDO $db): void {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
|
||||
+882
-281
File diff suppressed because it is too large
Load Diff
@@ -1847,6 +1847,41 @@ body.server-offline .bottom-nav {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.qty-control-with-unit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.qty-control-with-unit .qty-control {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.qty-unit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
height: 50px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: lowercase;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.qty-unit-badge.qty-unit-muted {
|
||||
background: var(--bg-card);
|
||||
color: var(--primary);
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
/* ===== USE OPTIONS ===== */
|
||||
.use-options {
|
||||
display: flex;
|
||||
|
||||
+341
-85
@@ -497,7 +497,8 @@ function _scaleUpdateLiveBox(msg) {
|
||||
}
|
||||
if (valEl) valEl.textContent = displayVal + stIcon;
|
||||
if (lblEl) {
|
||||
lblEl.textContent = '';
|
||||
const targetLbl = getUnitDisplayLabel(getActiveUseUnitLabel());
|
||||
lblEl.textContent = targetLbl ? ((t('qty.enter_in') || 'Inserimento in') + ' ' + targetLbl) : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -985,6 +986,7 @@ function _scaleShowReadingModal(targetInputId, unit) {
|
||||
<div style="padding:16px;text-align:center">
|
||||
<p style="margin-bottom:16px">${t('scale.place_on_scale')}</p>
|
||||
<div id="scale-reading-live" class="scale-reading-live">— — —</div>
|
||||
<p style="margin-top:8px;font-weight:700;color:var(--primary)">${escapeHtml((t('qty.enter_in') || 'Inserimento in') + ' ' + getUnitDisplayLabel(unit))}</p>
|
||||
<p class="settings-hint" style="margin-top:12px">${t('scale.waiting_stable')}</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -6357,6 +6359,76 @@ function formatQuantity(qty, unit, defaultQty, packageUnit) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Human-readable unit label for quantity inputs (pz, g, ml, conf…). */
|
||||
function getUnitDisplayLabel(unit) {
|
||||
const u = (unit || 'pz').toLowerCase();
|
||||
const map = {
|
||||
pz: t('units.pz') || 'pz',
|
||||
g: 'g',
|
||||
ml: 'ml',
|
||||
conf: t('units.conf') || 'conf',
|
||||
kg: 'kg',
|
||||
l: 'l',
|
||||
};
|
||||
return map[u] || u;
|
||||
}
|
||||
|
||||
/** Wrap qty input with a visible unit badge if missing. */
|
||||
function ensureQtyUnitBadge(inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return null;
|
||||
let badge = document.getElementById(inputId + '-unit');
|
||||
if (badge) return badge;
|
||||
const control = input.closest('.qty-control');
|
||||
if (!control) return null;
|
||||
let wrap = control.closest('.qty-control-with-unit');
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'qty-control-with-unit';
|
||||
control.parentNode.insertBefore(wrap, control);
|
||||
wrap.appendChild(control);
|
||||
}
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'qty-unit-badge';
|
||||
badge.id = inputId + '-unit';
|
||||
badge.setAttribute('aria-live', 'polite');
|
||||
badge.textContent = '—';
|
||||
wrap.appendChild(badge);
|
||||
return badge;
|
||||
}
|
||||
|
||||
function setQtyInputUnitLabel(inputId, unit, muted = false) {
|
||||
const badge = ensureQtyUnitBadge(inputId) || document.getElementById(inputId + '-unit');
|
||||
if (!badge) return;
|
||||
badge.textContent = getUnitDisplayLabel(unit);
|
||||
badge.classList.toggle('qty-unit-muted', !!muted);
|
||||
badge.title = (t('qty.unit_for_input') || 'Unità di misura') + ': ' + badge.textContent;
|
||||
}
|
||||
|
||||
function getActiveUseUnitLabel() {
|
||||
if (_useConfMode) {
|
||||
if (_useConfMode._activeUnit === 'conf') return 'conf';
|
||||
return _useConfMode.subLabel || _useConfMode.packageUnit || 'g';
|
||||
}
|
||||
return _useNormalUnit || 'pz';
|
||||
}
|
||||
|
||||
function getActiveRecipeUseUnitLabel() {
|
||||
if (_recipeUseConfMode) {
|
||||
if (_recipeUseConfMode._activeUnit === 'conf') return 'conf';
|
||||
return _recipeUseConfMode.subLabel || _recipeUseConfMode.packageUnit || 'g';
|
||||
}
|
||||
return _recipeUseNormalUnit || 'pz';
|
||||
}
|
||||
|
||||
function syncUseQtyUnitBadge() {
|
||||
setQtyInputUnitLabel('use-quantity', getActiveUseUnitLabel());
|
||||
}
|
||||
|
||||
function syncRecipeUseQtyUnitBadge() {
|
||||
setQtyInputUnitLabel('ruse-quantity', getActiveRecipeUseUnitLabel());
|
||||
}
|
||||
|
||||
// Structured quantity display for inventory cards.
|
||||
// Returns { mainQty: '10', unitLabel: 'conf', packageDetail: 'da 36g', fraction: '¼' }
|
||||
function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
|
||||
@@ -6387,7 +6459,8 @@ function formatQuantityParts(qty, unit, defaultQty, packageUnit) {
|
||||
|
||||
let packageDetail = '';
|
||||
let fraction = '';
|
||||
if (unit !== 'conf' && defaultQty && defaultQty > 1) {
|
||||
// pz = piece count only; default_quantity may hold legacy avg weight — ignore for display
|
||||
if (unit !== 'conf' && unit !== 'pz' && defaultQty && defaultQty > 1) {
|
||||
const d = parseFloat(defaultQty);
|
||||
const ratio = n / d;
|
||||
const remainder = ratio - Math.floor(ratio);
|
||||
@@ -6894,10 +6967,13 @@ function editInventoryItem(id) {
|
||||
<form class="form" onsubmit="submitEditInventory(event, ${id}, ${item.product_id})">
|
||||
<div class="form-group">
|
||||
<label>📦 ${t('inventory.label_quantity').replace('📦 ', '')}</label>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', -1)">−</button>
|
||||
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
|
||||
<div class="qty-control-with-unit">
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', -1)">−</button>
|
||||
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge" id="edit-qty-unit" aria-live="polite">${escapeHtml(getUnitDisplayLabel(item.unit || 'pz'))}</span>
|
||||
</div>
|
||||
${scaleEditReady ? `
|
||||
<div id="edit-scale-section" style="display:none;text-align:center;padding:10px;background:linear-gradient(135deg,#f3e8ff,#ede9fe);border-radius:10px;margin-top:8px">
|
||||
@@ -6951,10 +7027,12 @@ function editInventoryItem(id) {
|
||||
`;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
_initExpiryManualTracking('edit-expiry', item);
|
||||
setQtyInputUnitLabel('edit-qty', item.unit || 'pz');
|
||||
}
|
||||
|
||||
function onEditUnitChange() {
|
||||
const unit = document.getElementById('edit-unit').value;
|
||||
setQtyInputUnitLabel('edit-qty', unit);
|
||||
const confGroup = document.getElementById('edit-conf-size-group');
|
||||
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
|
||||
if (unit === 'conf') {
|
||||
@@ -8847,10 +8925,13 @@ function editActionInventoryItem(inventoryId) {
|
||||
<form class="form" onsubmit="submitActionEditInventory(event, ${inventoryId}, ${item.product_id})">
|
||||
<div class="form-group">
|
||||
<label>${t('add.quantity_label')}</label>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', -1)">−</button>
|
||||
<input type="number" id="action-edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', 1)">+</button>
|
||||
<div class="qty-control-with-unit">
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', -1)">−</button>
|
||||
<input type="number" id="action-edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('action-edit-qty', 1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge" id="action-edit-qty-unit" aria-live="polite">${escapeHtml(getUnitDisplayLabel(item.unit || 'pz'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -8899,10 +8980,12 @@ function editActionInventoryItem(inventoryId) {
|
||||
`;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
_initExpiryManualTracking('action-edit-expiry', item);
|
||||
setQtyInputUnitLabel('action-edit-qty', item.unit || 'pz');
|
||||
}
|
||||
|
||||
function onActionEditUnitChange() {
|
||||
const unit = document.getElementById('action-edit-unit').value;
|
||||
setQtyInputUnitLabel('action-edit-qty', unit);
|
||||
const confGroup = document.getElementById('action-edit-conf-group');
|
||||
if (confGroup) confGroup.style.display = unit === 'conf' ? 'block' : 'none';
|
||||
}
|
||||
@@ -8997,10 +9080,13 @@ function showThrowForm() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${t('use.throw_qty_label')}</label>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', -1)">−</button>
|
||||
<input type="number" id="throw-quantity" value="1" min="0.1" step="any" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', 1)">+</button>
|
||||
<div class="qty-control-with-unit">
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', -1)">−</button>
|
||||
<input type="number" id="throw-quantity" value="1" min="0.1" step="any" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('throw-quantity', 1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge" id="throw-quantity-unit" aria-live="polite">${escapeHtml(getUnitDisplayLabel(items[0]?.unit || 'pz'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-large btn-warning full-width" onclick="throwPartial()">
|
||||
@@ -9541,6 +9627,7 @@ function onAddUnitChange() {
|
||||
|
||||
// Show/hide scale read button based on new unit
|
||||
updateScaleReadButtons();
|
||||
setQtyInputUnitLabel('add-quantity', unit, true);
|
||||
}
|
||||
|
||||
function updateAddQtyStep() {
|
||||
@@ -9552,6 +9639,7 @@ function updateAddQtyStep() {
|
||||
} else {
|
||||
qtyInput.min = '1';
|
||||
}
|
||||
setQtyInputUnitLabel('add-quantity', unit, true);
|
||||
}
|
||||
|
||||
function markAddQtyManuallySet() {
|
||||
@@ -9838,26 +9926,28 @@ async function submitAdd(e) {
|
||||
}
|
||||
}
|
||||
showToast(t('add.product_added').replace('{name}', currentProduct.name).replace('{qty}', qtyInfo), 'success');
|
||||
if (result.removed_from_bring) {
|
||||
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
||||
} else if (shoppingItems.length > 0 && shoppingListUUID) {
|
||||
// PHP matching may have missed the item (custom name / no catalog match) —
|
||||
// try a client-side fuzzy remove using the already-loaded shoppingItems
|
||||
const match = _findSimilarItem(currentProduct.name, shoppingItems);
|
||||
if (match) {
|
||||
if (!(await spesaModeAfterAdd(result))) {
|
||||
if (result.removed_from_bring) {
|
||||
_applyShoppingListRemovals(result.removed_names || []);
|
||||
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
||||
} else if (shoppingListUUID) {
|
||||
const generic = currentProduct.shopping_name || currentProduct.name;
|
||||
const match = _findSimilarItem(generic, shoppingItems) || _findSimilarItem(currentProduct.name, shoppingItems);
|
||||
api('shopping_remove', {}, 'POST', {
|
||||
name: match.name,
|
||||
rawName: match.rawName || '',
|
||||
listUUID: shoppingListUUID
|
||||
name: match?.name || generic,
|
||||
rawName: match?.rawName || '',
|
||||
listUUID: shoppingListUUID,
|
||||
}).then(r => {
|
||||
if (r && r.success) {
|
||||
shoppingItems = shoppingItems.filter(i => i !== match);
|
||||
if (r?.success) {
|
||||
_applyShoppingListRemovals([match?.name || generic, match?.rawName].filter(Boolean));
|
||||
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
showPage('dashboard');
|
||||
} else if (result.removed_from_bring) {
|
||||
setTimeout(() => showToast(t('toast.removed_from_shopping'), 'info'), 1500);
|
||||
}
|
||||
if (!(await spesaModeAfterAdd())) showPage('dashboard');
|
||||
|
||||
// Submit extra batches (different expiry dates) in the background, silently
|
||||
if ((window._addExtraBatches || []).length > 0) {
|
||||
@@ -9911,6 +10001,7 @@ function showUseForm() {
|
||||
loadUseInventoryInfo();
|
||||
showPage('use');
|
||||
updateScaleReadButtons();
|
||||
syncUseQtyUnitBadge();
|
||||
}
|
||||
|
||||
function renderUsePreview() {
|
||||
@@ -10179,6 +10270,7 @@ async function loadUseInventoryInfo() {
|
||||
|
||||
// Trigger a live-box refresh with the latest reading if on scale
|
||||
if (_scaleLatestWeight) _scaleAutoFillUse(_scaleLatestWeight);
|
||||
syncUseQtyUnitBadge();
|
||||
} else {
|
||||
// --- NORMAL MODE ---
|
||||
_useConfMode = null;
|
||||
@@ -10216,6 +10308,7 @@ async function loadUseInventoryInfo() {
|
||||
</div>`;
|
||||
document.querySelector('#page-use .use-partial').appendChild(fracDiv);
|
||||
}
|
||||
syncUseQtyUnitBadge();
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
@@ -10254,6 +10347,7 @@ function switchUseUnit(mode) {
|
||||
hint.textContent = t('recipes.packs_of_have', { size: `${_useConfMode.packageSize}${_useConfMode.subLabel}`, count: _useConfMode.totalConf.toFixed(1) });
|
||||
if (confFracBtns) confFracBtns.style.display = '';
|
||||
}
|
||||
syncUseQtyUnitBadge();
|
||||
}
|
||||
|
||||
function setConfFraction(f) {
|
||||
@@ -11857,6 +11951,15 @@ function _isBringPurchased(name, urgency) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop smart-shopping rows the user already bought (blocklist from spesa). */
|
||||
function _filterPurchasedSmartItems(items) {
|
||||
if (!Array.isArray(items) || !items.length) return items || [];
|
||||
return items.filter(item => {
|
||||
const names = [item.shopping_name, item.name].filter(Boolean);
|
||||
return !names.some(n => _isBringPurchased(n, item.urgency));
|
||||
});
|
||||
}
|
||||
|
||||
async function autoAddCriticalItems() {
|
||||
// Time-based guard: run at most once every 5 minutes
|
||||
const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0');
|
||||
@@ -12605,7 +12708,7 @@ async function loadSmartShopping() {
|
||||
const prevCriticalNames = new Set(
|
||||
smartShoppingItems.filter(i => i.urgency === 'critical').map(i => i.name)
|
||||
);
|
||||
smartShoppingItems = data.items;
|
||||
smartShoppingItems = _filterPurchasedSmartItems(data.items);
|
||||
_smartShoppingLastFetch = Date.now();
|
||||
// NOTE: do NOT clear _cachedPrices here — qty validation (_qty/_unit metadata)
|
||||
// handles stale entries automatically item by item.
|
||||
@@ -13197,6 +13300,7 @@ async function renderShoppingItems() {
|
||||
return { item, idx, smartData, urgency, sec };
|
||||
});
|
||||
const enriched = _dedupeShoppingByGeneric(enrichedRaw);
|
||||
const pantryRows = enriched;
|
||||
|
||||
countEl.textContent = enriched.length;
|
||||
const tabCount = document.getElementById('tab-count-acquisto');
|
||||
@@ -13313,7 +13417,7 @@ async function renderShoppingItems() {
|
||||
// ── PANTRY HINTS: show "already at home: X" for each shopping item ──────
|
||||
// Load inventory once, then decorate all items asynchronously.
|
||||
_getShoppingInventoryCache().then(invItems => {
|
||||
for (const { item, idx, smartData, urgency } of enriched) {
|
||||
for (const { item, idx, smartData, urgency } of pantryRows) {
|
||||
const matches = _shoppingFamilyInventoryRows(item, smartData, invItems);
|
||||
if (matches.length === 0) continue;
|
||||
// Don't show "already at home" when the item is flagged urgent — stock is clearly insufficient.
|
||||
@@ -14320,6 +14424,7 @@ function viewArchivedRecipe(idx) {
|
||||
}
|
||||
|
||||
let _cachedRecipe = null;
|
||||
let _recipeShoppingSuggestions = [];
|
||||
let _generatedTodayTitles = []; // client-side list, robust vs race conditions
|
||||
let _recipeVariationCount = {}; // { 'pranzo': 0, 'cena': 1, ... }
|
||||
let _rejectedRecipeIngredients = []; // ingredient names from previously rejected recipes
|
||||
@@ -14419,6 +14524,14 @@ function _normalizeRecipeIngQtyNumber(ing) {
|
||||
const pkgUnit = (ing.package_unit || '').toLowerCase();
|
||||
const isConfSub = unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml');
|
||||
let useQty = parseFloat(ing.qty_number) || 0;
|
||||
const stockPieces = parseFloat(ing.inventory_qty_total ?? ing.inventory_qty) || 0;
|
||||
|
||||
if (unit === 'pz') {
|
||||
useQty = _recipeResolvePieceQty(useQty, recipeVal, recipeUnit, stockPieces);
|
||||
ing.qty_number = Math.round(useQty * 1000) / 1000;
|
||||
ing.qty = _recipeFormatPieceQtyLabel(useQty);
|
||||
return useQty;
|
||||
}
|
||||
|
||||
if (isConfSub && recipeVal > 0 && recipeUnit === pkgUnit) {
|
||||
useQty = recipeVal;
|
||||
@@ -14435,17 +14548,84 @@ function _normalizeRecipeIngQtyNumber(ing) {
|
||||
return useQty;
|
||||
}
|
||||
|
||||
function _recipeRoundPieceQty(n) {
|
||||
return Math.max(0.25, Math.round(n * 4) / 4);
|
||||
}
|
||||
|
||||
function _recipeFormatPieceQtyLabel(n) {
|
||||
const whole = Math.floor(n);
|
||||
const frac = Math.round((n - whole) * 4) / 4;
|
||||
const fracMap = { 0.25: '¼', 0.5: '½', 0.75: '¾' };
|
||||
const fracStr = fracMap[frac] || '';
|
||||
if (whole === 0) return (fracStr || '0') + ' pz';
|
||||
return whole + fracStr + ' pz';
|
||||
}
|
||||
|
||||
/** Piece inventory only — never derive count from default_quantity / grams. */
|
||||
function _recipeResolvePieceQty(rawQty, recipeVal, recipeUnit, stockPieces) {
|
||||
stockPieces = Math.max(0, stockPieces);
|
||||
if (recipeUnit === 'pz' && recipeVal > 0) {
|
||||
return _recipeRoundPieceQty(Math.min(recipeVal, stockPieces > 0 ? stockPieces : recipeVal));
|
||||
}
|
||||
if (rawQty >= 0.25 && rawQty <= Math.min(stockPieces > 0 ? stockPieces : 50, 50)) {
|
||||
return _recipeRoundPieceQty(rawQty);
|
||||
}
|
||||
if (rawQty >= 20 && (stockPieces <= 0 || rawQty > stockPieces)) {
|
||||
return _recipeRoundPieceQty(Math.min(1, stockPieces > 0 ? stockPieces : 1));
|
||||
}
|
||||
if (recipeVal >= 0.25 && recipeVal <= 50 && !['g', 'ml', 'kg', 'l'].includes(recipeUnit)) {
|
||||
return _recipeRoundPieceQty(Math.min(recipeVal, stockPieces > 0 ? stockPieces : recipeVal));
|
||||
}
|
||||
return _recipeRoundPieceQty(Math.min(1, stockPieces > 0 ? stockPieces : 1));
|
||||
}
|
||||
|
||||
function _recipeGetServingCapForIngredient(name, unit, persons) {
|
||||
if (!persons || persons <= 0) return null;
|
||||
const n = (name || '').toLowerCase().replace(/\s+/g, ' ');
|
||||
if (unit === 'pz') {
|
||||
if (/\b(cipoll\w*|porr\w*|scalog\w*)\b/.test(n)) return persons;
|
||||
if (/\b(peperon\w*|melanzan\w*|zucchin\w*|finocchi\w*|melone)\b/.test(n)) return persons;
|
||||
if (/\b(limon\w*|aranc\w*|limett\w*)\b/.test(n)) return Math.max(1, Math.ceil(0.5 * persons));
|
||||
if (/\b(dado|brodo)\b/.test(n)) return Math.min(persons, 1);
|
||||
if (/\b(baulett\w*|panin\w*|toast|piadin\w*|grissin\w*)\b/.test(n)) return Math.min(2, persons);
|
||||
return null;
|
||||
}
|
||||
if (unit === 'g' || unit === 'ml') {
|
||||
if (/\b(spinac\w*|bietol\w*|rucol\w*|lattug\w*|valerian\w*|songin\w*|misticanz\w*|indivi\w*|radicchi\w*|cicori\w*)\b/.test(n)) return 150 * persons;
|
||||
if (/\b(minestr\w*|verdure)\b/.test(n)) return 200 * persons;
|
||||
if (/\b(pane\s*gratt|grattugi\w*|pangratt)\b/.test(n)) return 30 * persons;
|
||||
if (/\b(zucchin\w*|melanzan\w*|peperon\w*|carot\w*|sedan\w*|finocchi\w*|cavolf\w*|broccol\w*|zucc\w*|pomodor\w*|verdur\w*)\b/.test(n)) return 150 * persons;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _recipeClampQtyForServings(ing, persons) {
|
||||
if (!persons || persons <= 0) return;
|
||||
const unit = ing.inventory_unit || 'pz';
|
||||
let qty = parseFloat(ing.qty_number) || 0;
|
||||
if (qty <= 0) return;
|
||||
const cap = _recipeGetServingCapForIngredient(ing.name, unit, persons);
|
||||
if (cap === null || qty <= cap) return;
|
||||
ing.qty_number = Math.round(cap * 100) / 100;
|
||||
if (unit === 'pz') ing.qty = _recipeFormatPieceQtyLabel(cap);
|
||||
else if (unit === 'g' || unit === 'ml') ing.qty = Math.round(cap) + ' ' + unit;
|
||||
delete ing.use_all_suggested;
|
||||
if (ing.stock_have != null) ing.stock_remain = Math.max(0, Math.round((ing.stock_have - cap) * 100) / 100);
|
||||
}
|
||||
|
||||
function _recipeGetClosedProductBaseQty(ing) {
|
||||
const unit = ing.inventory_unit || 'pz';
|
||||
const pkgSize = parseFloat(ing.default_quantity) || 0;
|
||||
const pkgUnit = (ing.package_unit || '').toLowerCase();
|
||||
// Countable items: one piece = one unit — ignore default_quantity weight in grams
|
||||
if (unit === 'pz') return 1;
|
||||
if (unit === 'conf' && pkgSize > 0 && (pkgUnit === 'g' || pkgUnit === 'ml')) {
|
||||
return pkgSize;
|
||||
}
|
||||
if (unit === 'conf' && pkgSize > 0) {
|
||||
return pkgSize;
|
||||
}
|
||||
if (pkgSize > 0 && (unit === 'g' || unit === 'ml' || unit === 'pz')) {
|
||||
if (pkgSize > 0 && (unit === 'g' || unit === 'ml')) {
|
||||
return pkgSize;
|
||||
}
|
||||
if (unit === 'conf') {
|
||||
@@ -14495,7 +14675,9 @@ function _computeRecipeIngStockHint(ing, totalStockQty) {
|
||||
ing.qty = Math.round(useDisp) + ' ' + pkgUnit;
|
||||
} else {
|
||||
ing.qty_number = Math.round(totalStockQty * 1000) / 1000;
|
||||
ing.qty = (unit === 'pz' ? (Math.round(totalStockQty * 100) / 100) : Math.round(totalStockQty)) + ' ' + (unit === 'pz' ? t('units.pz') : unit);
|
||||
ing.qty = unit === 'pz'
|
||||
? _recipeFormatPieceQtyLabel(totalStockQty)
|
||||
: Math.round(totalStockQty) + ' ' + unit;
|
||||
}
|
||||
} else {
|
||||
delete ing.use_all_suggested;
|
||||
@@ -14551,6 +14733,7 @@ async function enrichRecipeIngredientsStock(recipe) {
|
||||
ing.package_unit = pick.package_unit;
|
||||
}
|
||||
_computeRecipeIngStockHint(ing, totalStock);
|
||||
_recipeClampQtyForServings(ing, Math.max(1, parseInt(recipe.persons, 10) || 1));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('enrichRecipeIngredientsStock:', e);
|
||||
@@ -14659,11 +14842,14 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
||||
<button type="button" class="use-unit-btn" id="ruse-unit-conf" onclick="switchRecipeUseUnit('conf')">${t('recipes.packs_label')}</button>
|
||||
</div>
|
||||
<p id="ruse-hint" style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.quantity_in_total').replace('{unit}', subLabel).replace('{total}', Math.round(totalSub) + subLabel)}</p>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)">−</button>
|
||||
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${step}" step="${step}" class="qty-input"
|
||||
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
|
||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
|
||||
<div class="qty-control-with-unit">
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)">−</button>
|
||||
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${step}" step="${step}" class="qty-input"
|
||||
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
|
||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge" id="ruse-quantity-unit" aria-live="polite">${escapeHtml(subLabel)}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
_recipeUseNormalUnit = unit;
|
||||
@@ -14671,12 +14857,15 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
||||
const unitLabel = unitLabels[unit] || unit;
|
||||
const inputMin = '0.1';
|
||||
qtySection = `
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.amount_label')} (${unitLabel}):</p>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)">−</button>
|
||||
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${inputMin}" step="any" class="qty-input"
|
||||
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
|
||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:8px">${t('recipes.amount_label')}:</p>
|
||||
<div class="qty-control-with-unit">
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(-1)">−</button>
|
||||
<input type="number" id="ruse-quantity" value="${defaultQtyValue}" min="${inputMin}" step="any" class="qty-input"
|
||||
oninput="_scaleRecipeAutoFillPaused=true; _cancelScaleAutoConfirm(false); var h=document.getElementById('ruse-scale-hint'); if(h) h.style.display='none';">
|
||||
<button type="button" class="qty-btn" onclick="adjustRecipeUseQty(1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge" id="ruse-quantity-unit" aria-live="polite">${escapeHtml(unitLabel)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -14733,6 +14922,7 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, rec
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
syncRecipeUseQtyUnitBadge();
|
||||
|
||||
} catch (err) {
|
||||
console.error('useRecipeIngredient error:', err);
|
||||
@@ -14771,6 +14961,7 @@ function switchRecipeUseUnit(mode) {
|
||||
qtyInput.min = 0.5;
|
||||
hint.textContent = t('recipes.packs_of_have').replace('{size}', `${_recipeUseConfMode.packageSize}${_recipeUseConfMode.subLabel}`).replace('{count}', _recipeUseConfMode.totalConf.toFixed(1));
|
||||
}
|
||||
syncRecipeUseQtyUnitBadge();
|
||||
}
|
||||
|
||||
function adjustRecipeUseQty(direction) {
|
||||
@@ -15047,6 +15238,30 @@ function scaleRecipePersons(delta) {
|
||||
_updateRecipeStockHintsAfterScale(ratio);
|
||||
}
|
||||
|
||||
async function addRecipeShoppingSuggestions() {
|
||||
const items = (_recipeShoppingSuggestions || []).filter(s => s && s.name);
|
||||
if (!items.length) return;
|
||||
try {
|
||||
const payload = {
|
||||
items: items.map(s => ({
|
||||
name: s.name,
|
||||
specification: s.qty ? `Da ricetta · ${s.qty}` : 'Da ricetta',
|
||||
})),
|
||||
listUUID: typeof shoppingListUUID !== 'undefined' ? shoppingListUUID : undefined,
|
||||
};
|
||||
const data = await api('shopping_add', {}, 'POST', payload);
|
||||
if (data.success) {
|
||||
showToast(t('recipes.shopping_suggestions_added'), 'success');
|
||||
if (typeof loadShoppingCount === 'function') loadShoppingCount();
|
||||
} else {
|
||||
showToast(data.error || t('error.bring_add'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('addRecipeShoppingSuggestions:', e);
|
||||
showToast(t('error.bring_add'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function renderRecipe(r) {
|
||||
await enrichRecipeIngredientsStock(r);
|
||||
// Reset regen choice panel (hide choice, show button)
|
||||
@@ -15093,6 +15308,20 @@ async function renderRecipe(r) {
|
||||
html += `<div class="recipe-tools-banner">🔧 <strong>${escapeHtml(t('recipes.tools_title'))}:</strong> ${tools.map(tool => `<span class="recipe-tool-chip">${escapeHtml(tool)}</span>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
// Optional shopping suggestions (ingredients removed because not in pantry)
|
||||
const shopSug = r.shopping_suggestions || [];
|
||||
if (shopSug.length > 0) {
|
||||
const items = shopSug.map(s => `<li><strong>${escapeHtml(s.name)}</strong>${s.qty ? ': ' + escapeHtml(s.qty) : ''}</li>`).join('');
|
||||
html += `<div class="recipe-shopping-suggestions" id="recipe-shopping-suggestions">
|
||||
<p>🛒 ${escapeHtml(t('recipes.shopping_suggestions_intro'))}</p>
|
||||
<ul>${items}</ul>
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="addRecipeShoppingSuggestions()">${escapeHtml(t('recipes.shopping_suggestions_add'))}</button>
|
||||
</div>`;
|
||||
_recipeShoppingSuggestions = shopSug;
|
||||
} else {
|
||||
_recipeShoppingSuggestions = [];
|
||||
}
|
||||
|
||||
// Ingredients
|
||||
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
|
||||
(r.ingredients || []).forEach((ing, idx) => {
|
||||
@@ -15106,6 +15335,9 @@ async function renderRecipe(r) {
|
||||
let details = [];
|
||||
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
||||
details.push(ingredientLocLabels[ing.location] || ('📍 ' + ing.location));
|
||||
if (ing.location === 'freezer') {
|
||||
details.push(t('recipes.frozen_badge') || '❄️ Surgelato');
|
||||
}
|
||||
if (ing.expiry_date) {
|
||||
const exp = new Date(ing.expiry_date);
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
@@ -15125,10 +15357,8 @@ async function renderRecipe(r) {
|
||||
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this, '${(ing.qty || '').replace(/'/g, "'")}')" title="${t('cooking.ingredient_deduct_title')}">${t('cooking.ingredient_use_btn')}</button>`;
|
||||
}
|
||||
html += `</li>`;
|
||||
} else {
|
||||
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
|
||||
html += `<li class="recipe-ingredient" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}"><span class="recipe-ing-text"><strong>${escapeHtml(ing.name)}</strong>: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span>${pantryIcon}</span></li>`;
|
||||
}
|
||||
// Non-pantry ingredients are stripped server-side; nothing to render here.
|
||||
});
|
||||
html += '</ul>';
|
||||
|
||||
@@ -18300,8 +18530,28 @@ function _applySpesaScanUI() {
|
||||
_updateScanAiButton();
|
||||
}
|
||||
|
||||
/** Drop purchased items from in-memory Bring list (by Italian or catalog name). */
|
||||
function _applyShoppingListRemovals(removedNames) {
|
||||
if (!removedNames?.length) return;
|
||||
const keys = new Set();
|
||||
for (const n of removedNames) {
|
||||
const low = String(n || '').trim().toLowerCase();
|
||||
if (low) keys.add(low);
|
||||
const tok = _nameTokens(low)[0];
|
||||
if (tok) keys.add(tok);
|
||||
}
|
||||
if (!keys.size) return;
|
||||
shoppingItems = (shoppingItems || []).filter(item => {
|
||||
const nameLow = (item.name || '').toLowerCase();
|
||||
const rawLow = (item.rawName || '').toLowerCase();
|
||||
const first = _nameTokens(nameLow)[0] || '';
|
||||
return !keys.has(nameLow) && !keys.has(rawLow) && !(first && keys.has(first));
|
||||
});
|
||||
loadShoppingCount();
|
||||
}
|
||||
|
||||
// Called after successful add — returns true if spesa mode handled navigation
|
||||
async function spesaModeAfterAdd() {
|
||||
async function spesaModeAfterAdd(addResult) {
|
||||
if (!_spesaMode) return false;
|
||||
if (currentProduct) {
|
||||
_spesaSession.push({
|
||||
@@ -18311,7 +18561,7 @@ async function spesaModeAfterAdd() {
|
||||
});
|
||||
updateSpesaBanner();
|
||||
_shoppingInventoryCache = null;
|
||||
await _spesaRemovePurchasedFromList(currentProduct);
|
||||
await _spesaRemovePurchasedFromList(currentProduct, addResult);
|
||||
const addLoc = document.getElementById('add-location')?.value || 'dispensa';
|
||||
_showFamilySiblingSuggest(currentProduct.id, addLoc);
|
||||
}
|
||||
@@ -18320,27 +18570,34 @@ async function spesaModeAfterAdd() {
|
||||
}
|
||||
|
||||
/** Remove matching shopping-list / Bring entry after a spesa-mode purchase. */
|
||||
async function _spesaRemovePurchasedFromList(product) {
|
||||
async function _spesaRemovePurchasedFromList(product, addResult) {
|
||||
const namesToMark = [product.name];
|
||||
if (product.shopping_name) namesToMark.push(product.shopping_name);
|
||||
if (shoppingListUUID && shoppingItems.length > 0) {
|
||||
const generic = product.shopping_name || product.name;
|
||||
const match = _findSimilarItem(generic, shoppingItems) || _findSimilarItem(product.name, shoppingItems);
|
||||
if (match) {
|
||||
try {
|
||||
const r = await api('shopping_remove', {}, 'POST', {
|
||||
name: match.name,
|
||||
rawName: match.rawName || '',
|
||||
listUUID: shoppingListUUID,
|
||||
});
|
||||
if (r?.success) {
|
||||
shoppingItems = shoppingItems.filter(i => i !== match);
|
||||
namesToMark.push(match.name);
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
if (addResult?.removed_names?.length) {
|
||||
_applyShoppingListRemovals(addResult.removed_names);
|
||||
namesToMark.push(...addResult.removed_names);
|
||||
}
|
||||
|
||||
const generic = product.shopping_name || product.name;
|
||||
if (!addResult?.removed_from_bring) {
|
||||
try {
|
||||
const match = _findSimilarItem(generic, shoppingItems) || _findSimilarItem(product.name, shoppingItems);
|
||||
const r = await api('shopping_remove', {}, 'POST', {
|
||||
name: match?.name || generic,
|
||||
rawName: match?.rawName || '',
|
||||
listUUID: shoppingListUUID || undefined,
|
||||
});
|
||||
if (r?.success) {
|
||||
_applyShoppingListRemovals([match?.name || generic, match?.rawName].filter(Boolean));
|
||||
if (match?.name) namesToMark.push(match.name);
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
_markBringPurchased(namesToMark);
|
||||
loadShoppingList._bgCall = true;
|
||||
loadShoppingList();
|
||||
}
|
||||
|
||||
const _FAMILY_SIBLING_CONFIRM_TTL = 24 * 60 * 60 * 1000;
|
||||
@@ -18936,27 +19193,6 @@ function _heartbeatRetry() {
|
||||
* Returns true if the app can proceed, false if a critical check failed.
|
||||
*/
|
||||
async function _runStartupCheck() {
|
||||
const spinnerEl = document.getElementById('preloader-spinner');
|
||||
const wrapEl = document.getElementById('preloader-progress-wrap');
|
||||
const barEl = document.getElementById('preloader-bar');
|
||||
const labelEl = document.getElementById('preloader-check-label');
|
||||
const warningsEl = document.getElementById('preloader-warnings');
|
||||
const errorEl = document.getElementById('preloader-error-msg');
|
||||
const retryBtn = document.getElementById('preloader-retry-btn');
|
||||
|
||||
if (!wrapEl) return true; // preloader already removed
|
||||
|
||||
const tl = (key, fallback) => {
|
||||
const full = 'startup.' + key;
|
||||
const v = typeof t === 'function' ? t(full) : full;
|
||||
return (v === full) ? fallback : v;
|
||||
};
|
||||
|
||||
// Switch from spinner to progress bar
|
||||
if (spinnerEl) spinnerEl.style.display = 'none';
|
||||
wrapEl.style.display = '';
|
||||
|
||||
// Helper: set progress bar + crossfade status text (function decl avoids TDZ if called early)
|
||||
let _curPct = 0;
|
||||
function setProgress(pct, label, state) {
|
||||
_curPct = pct;
|
||||
@@ -18981,6 +19217,26 @@ async function _runStartupCheck() {
|
||||
el.textContent = cleanLabel;
|
||||
}
|
||||
|
||||
const spinnerEl = document.getElementById('preloader-spinner');
|
||||
const wrapEl = document.getElementById('preloader-progress-wrap');
|
||||
const barEl = document.getElementById('preloader-bar');
|
||||
const labelEl = document.getElementById('preloader-check-label');
|
||||
const warningsEl = document.getElementById('preloader-warnings');
|
||||
const errorEl = document.getElementById('preloader-error-msg');
|
||||
const retryBtn = document.getElementById('preloader-retry-btn');
|
||||
|
||||
if (!wrapEl) return true; // preloader already removed
|
||||
|
||||
const tl = (key, fallback) => {
|
||||
const full = 'startup.' + key;
|
||||
const v = typeof t === 'function' ? t(full) : full;
|
||||
return (v === full) ? fallback : v;
|
||||
};
|
||||
|
||||
// Switch from spinner to progress bar
|
||||
if (spinnerEl) spinnerEl.style.display = 'none';
|
||||
wrapEl.style.display = '';
|
||||
|
||||
// Auto-provision API token for same-origin browser sessions
|
||||
if (typeof ensureApiToken === 'function') {
|
||||
setProgress(5, tl('token_autoconfig', 'Configurazione accesso...'), 'ok');
|
||||
|
||||
+12
-8
@@ -94,7 +94,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.39</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.40</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,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.39</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.40</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -420,6 +420,7 @@
|
||||
<input type="number" id="add-quantity" value="1" min="0.1" step="any" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge qty-unit-muted" id="add-quantity-unit" aria-live="polite">pz</span>
|
||||
<select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()">
|
||||
<option value="pz">pz</option>
|
||||
<option value="conf">conf</option>
|
||||
@@ -499,11 +500,14 @@
|
||||
</button>
|
||||
<div class="use-partial">
|
||||
<p id="use-partial-hint" data-i18n="use.partial_hint">Oppure specifica la quantità usata:</p>
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
||||
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
||||
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
||||
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
||||
<div class="qty-control-with-unit">
|
||||
<div class="qty-control">
|
||||
<button type="button" class="qty-btn" id="use-qty-minus" onclick="adjustUseQty(-1)">−</button>
|
||||
<input type="number" id="use-quantity" value="1" min="0.1" step="any" class="qty-input"
|
||||
oninput="_scaleUserDismissed=true; _cancelScaleTimersOnly();">
|
||||
<button type="button" class="qty-btn" id="use-qty-plus" onclick="adjustUseQty(1)">+</button>
|
||||
</div>
|
||||
<span class="qty-unit-badge" id="use-quantity-unit" aria-live="polite">—</span>
|
||||
</div>
|
||||
<button type="submit" id="btn-use-submit" class="btn btn-large btn-warning full-width mt-2 move-countdown-btn" data-i18n="use.submit">📤 Usa questa quantità</button>
|
||||
</div>
|
||||
@@ -1985,6 +1989,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260606n"></script>
|
||||
<script src="assets/js/app.js?v=20260606z"></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.39",
|
||||
"version": "1.7.40",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* One-shot triage: comment + close resolved auto-report bugs; reply on #200 (keep open).
|
||||
* Usage: php scripts/triage-open-issues.php [--dry-run]
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
define('CRON_MODE', true);
|
||||
require_once __DIR__ . '/../api/bootstrap.php';
|
||||
require_once __DIR__ . '/../api/lib/github.php';
|
||||
require_once __DIR__ . '/../api/lib/constants.php';
|
||||
|
||||
$dryRun = in_array('--dry-run', $argv ?? [], true);
|
||||
$repo = GH_REPO;
|
||||
$token = _ghToken();
|
||||
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function ghApi(string $token, string $method, string $url, array $payload = []): array {
|
||||
$ch = curl_init($url);
|
||||
$headers = [
|
||||
'Authorization: token ' . $token,
|
||||
'Accept: application/vnd.github+json',
|
||||
'X-GitHub-Api-Version: 2022-11-28',
|
||||
'User-Agent: EverShelf-Triage/1.0',
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
]);
|
||||
if ($method === 'PATCH') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
} elseif ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
}
|
||||
$raw = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
|
||||
}
|
||||
|
||||
function commentIssue(string $token, string $repo, int $num, string $body, bool $dryRun): bool {
|
||||
if ($dryRun) {
|
||||
echo "[dry-run] comment #$num\n";
|
||||
return true;
|
||||
}
|
||||
$r = ghApi($token, 'POST', "https://api.github.com/repos/$repo/issues/$num/comments", ['body' => $body]);
|
||||
if ($r['http_code'] >= 200 && $r['http_code'] < 300) {
|
||||
echo "OK comment #$num\n";
|
||||
return true;
|
||||
}
|
||||
fwrite(STDERR, "FAIL comment #$num HTTP {$r['http_code']}: " . json_encode($r['body']) . "\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
function closeIssue(string $token, string $repo, int $num, bool $dryRun): bool {
|
||||
if ($dryRun) {
|
||||
echo "[dry-run] close #$num\n";
|
||||
return true;
|
||||
}
|
||||
$r = ghApi($token, 'PATCH', "https://api.github.com/repos/$repo/issues/$num", ['state' => 'closed']);
|
||||
if ($r['http_code'] >= 200 && $r['http_code'] < 300) {
|
||||
echo "OK close #$num\n";
|
||||
return true;
|
||||
}
|
||||
fwrite(STDERR, "FAIL close #$num HTTP {$r['http_code']}: " . json_encode($r['body']) . "\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── #200: reply only, keep OPEN ─────────────────────────────────────────────
|
||||
$body200 = <<<'MD'
|
||||
Ciao Marco, grazie per la segnalazione dettagliata.
|
||||
|
||||
Il messaggio **«Impossibile contattare il server»** compare quando il browser **non riesce a completare** la richiesta a `api/index.php?action=health_check`. Quindi phpinfo funziona, ma **l'endpoint API no** (404, redirect, TLS, path sbagliato, ecc.).
|
||||
|
||||
### Check rapidi (dalla macchina dove apri il browser)
|
||||
|
||||
```bash
|
||||
curl -sv "https://TUO-DOMINIO/api/index.php?action=ping"
|
||||
curl -sv "https://TUO-DOMINIO/api/index.php?action=health_check"
|
||||
```
|
||||
|
||||
Se uno dei due fallisce: DevTools → **Network** → URL esatto e **status code** della richiesta `health_check`.
|
||||
|
||||
### Cause frequenti con Traefik + Docker Swarm
|
||||
|
||||
1. **Routing incompleto** — Traefik deve inoltrare `/` **e** `/api/*`, non solo la homepage.
|
||||
2. **Redirect HTTPS** — dietro Traefik serve `X-Forwarded-Proto: https`, oppure disabilitare il redirect in `.htaccess`. Nelle immagini recenti il Dockerfile imposta `SetEnvIf X-Forwarded-Proto "https" HTTPS=on`.
|
||||
3. **Sottopath** — EverShelf usa URL relativi (`api/index.php`); se l'app è su `/sottocartella/`, l'URL pubblico deve essere coerente.
|
||||
4. **Volume `data/`** — al primo avvio può essere quasi vuoto; assicurati permessi scrivibili:
|
||||
```bash
|
||||
docker exec -it CONTAINER chown -R www-data:www-data /var/www/html/data
|
||||
docker exec -it CONTAINER chmod -R 775 /var/www/html/data
|
||||
```
|
||||
5. **`API_TOKEN` in `.env`** — se impostato, compare un prompt token (non «server non raggiungibile»).
|
||||
|
||||
### Per il passo successivo
|
||||
|
||||
Puoi condividere:
|
||||
- URL pubblico esatto (con path)
|
||||
- Output dei due `curl` sopra
|
||||
- Screenshot Network tab su `health_check`
|
||||
- Labels Traefik del servizio (router + middlewares)
|
||||
|
||||
Resta aperta finché non confermi che `ping`/`health_check` rispondono — poi chiudiamo insieme.
|
||||
MD;
|
||||
|
||||
commentIssue($token, $repo, 200, $body200, $dryRun);
|
||||
|
||||
// ── Resolved auto-report bugs ───────────────────────────────────────────────
|
||||
$bugs = [
|
||||
198 => "Risolto in develop: `PRAGMA busy_timeout` portato a 10s e `dbWithRetry()` su `updateInventory` per ritentare su SQLITE_BUSY quando cron smart-shopping e PWA scrivono in parallelo.",
|
||||
199 => "Duplicato di #198 — stesso evento (`inventory_update` → database locked). Fix: retry + busy_timeout aumentato.",
|
||||
196 => "Risolto in v1.7.38+: `saveProduct` intercetta `UNIQUE constraint failed: products.barcode`, fa merge sul prodotto esistente o risponde 409 JSON (`barcode_already_used`) invece di HTTP 500.",
|
||||
197 => "Conseguenza lato PWA del crash PHP #196 — risolto con gestione barcode duplicato in `saveProduct`.",
|
||||
195 => "Risolto: `EverLog::request()` ora riceve sempre stringhe — `\$method = (string)(\$_SERVER['REQUEST_METHOD'] ?? 'GET')` (fix CLI/cron che passavano null).",
|
||||
193 => "Stesso root cause di #195 (fatal TypeError su `EverLog::request` con method null da CLI). Fix già in develop.",
|
||||
194 => "Risolto: `_applySpesaScanUI` usava `currentPage` (inesistente) → corretto in `_currentPageId`.",
|
||||
192 => "Risolto: in `renderShoppingItems` la variabile `enriched` veniva referenziata prima della dichiarazione (TDZ). Ora `enrichedRaw` → `_dedupeShoppingByGeneric` → `enriched`.",
|
||||
191 => "Risolto: in `_runStartupCheck` `setProgress` è dichiarata prima delle chiamate e `barEl` inizializzato prima dell'uso (niente più TDZ).",
|
||||
134 => "Segnalazione auto-report su volume Docker non scrivibile. Mitigazioni: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile `chown www-data`. Su Swarm: `chown -R www-data:www-data data` al primo boot.",
|
||||
184 => "Correlato a #134: SQLite readonly quando `data/` o `evershelf.db` non sono scrivibili. Fix operativo + chmod WAL/SHM sidecar in `_ensureDbWritable()`.",
|
||||
];
|
||||
|
||||
foreach ($bugs as $num => $msg) {
|
||||
commentIssue($token, $repo, $num, $msg . "\n\n_Chiuso dopo triage — fix in develop._", $dryRun);
|
||||
closeIssue($token, $repo, $num, $dryRun);
|
||||
}
|
||||
|
||||
// ── Feature / enhancement backlog (close with acknowledgment) ───────────────
|
||||
$features = [122, 121, 120, 119, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
|
||||
$featMsg = <<<'MD'
|
||||
Grazie per la proposta — è nel **backlog** del progetto.
|
||||
|
||||
Chiudiamo questa issue per tenere il tracker focalizzato sui bug attivi; la funzionalità resta nel radar per release future. **Riapri pure** quando vuoi lavorarci o seguirne lo sviluppo.
|
||||
MD;
|
||||
|
||||
foreach ($features as $num) {
|
||||
commentIssue($token, $repo, $num, $featMsg, $dryRun);
|
||||
closeIssue($token, $repo, $num, $dryRun);
|
||||
}
|
||||
|
||||
echo "Done.\n";
|
||||
@@ -427,6 +427,11 @@
|
||||
"regen_save_new": "💾 Save to archive & generate a new one",
|
||||
"close_btn": "✅ Close",
|
||||
"ingredients_title": "🧾 Ingredients",
|
||||
"shopping_suggestions_intro": "For an alternative version you'd need (not in pantry — optional):",
|
||||
"shopping_suggestions_add": "Add to shopping list",
|
||||
"shopping_suggestions_added": "Added to shopping list",
|
||||
"unit_for_input": "Unit of measure",
|
||||
"enter_in": "Enter value in",
|
||||
"tools_title": "Equipment needed",
|
||||
"steps_title": "👨🍳 Steps",
|
||||
"no_steps": "No steps available",
|
||||
|
||||
@@ -427,6 +427,12 @@
|
||||
"regen_save_new": "💾 Salva nell'archivio e genera una nuova",
|
||||
"close_btn": "✅ Chiudi",
|
||||
"ingredients_title": "🧾 Ingredienti",
|
||||
"shopping_suggestions_intro": "Per una variante servirebbe (non in dispensa — opzionale):",
|
||||
"shopping_suggestions_add": "Aggiungi alla lista spesa",
|
||||
"shopping_suggestions_added": "Aggiunto alla lista spesa",
|
||||
"frozen_badge": "surgelato — dal freezer",
|
||||
"unit_for_input": "Unità di misura",
|
||||
"enter_in": "Inserimento in",
|
||||
"tools_title": "Strumenti necessari",
|
||||
"steps_title": "👨🍳 Procedimento",
|
||||
"no_steps": "Nessun procedimento disponibile",
|
||||
|
||||
Reference in New Issue
Block a user