chore: auto-merge develop → main
Triggered by: 561c6e9 ci: fix auto-merge — clear checkout extraheader so WORKFLOW_PAT actually reaches git push
This commit is contained in:
@@ -102,7 +102,9 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Always use the built-in GITHUB_TOKEN for checkout (read-only fetch).
|
||||
# WORKFLOW_PAT is only needed for the push step below.
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Configure git bot identity
|
||||
run: |
|
||||
@@ -111,6 +113,15 @@ jobs:
|
||||
|
||||
- name: Merge develop → main
|
||||
run: |
|
||||
# ── ROOT CAUSE FIX ──────────────────────────────────────────────────
|
||||
# actions/checkout writes an http.extraheader (AUTHORIZATION: basic …)
|
||||
# that silently overrides any credentials embedded in git remote URLs.
|
||||
# We must clear it BEFORE setting the remote URL with WORKFLOW_PAT,
|
||||
# otherwise GITHUB_TOKEN is always used for the push and workflow-file
|
||||
# changes are rejected.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
git config --local --unset-all http."https://github.com/".extraheader 2>/dev/null || true
|
||||
|
||||
LAST=$(git log --oneline -1 origin/develop)
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
@@ -118,6 +129,26 @@ jobs:
|
||||
-m "chore: auto-merge develop → main
|
||||
|
||||
Triggered by: $LAST"
|
||||
|
||||
# ── PUSH STRATEGY ───────────────────────────────────────────────────
|
||||
# Priority 1: WORKFLOW_PAT (classic PAT, repo+workflow scopes)
|
||||
# → can push workflow file changes; set as a repo secret.
|
||||
# Priority 2: GITHUB_TOKEN fallback
|
||||
# → cannot push workflow files; strip them from the merge commit.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
PUSH_TOKEN="${{ secrets.WORKFLOW_PAT }}"
|
||||
if [ -z "$PUSH_TOKEN" ]; then
|
||||
WF=$(git diff --name-only origin/main -- .github/workflows/ 2>/dev/null || echo "")
|
||||
if [ -n "$WF" ]; then
|
||||
echo "::warning::WORKFLOW_PAT not set — stripping workflow changes from merge commit:"
|
||||
echo "$WF"
|
||||
git checkout origin/main -- .github/workflows/
|
||||
git diff --cached --quiet || git commit --amend --no-edit
|
||||
fi
|
||||
PUSH_TOKEN="${{ github.token }}"
|
||||
fi
|
||||
|
||||
git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
git push origin main
|
||||
|
||||
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
||||
|
||||
@@ -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.25] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately after removing an item, so the purchased blocklist is populated before the next background refresh. `autoAddCriticalItems` now also respects the blocklist for depleted items (quantity = 0), preventing re-addition of just-bought products.
|
||||
- **Barcode lookup false "not found"** — Added `_offFetchProduct()` PHP helper that tries up to three barcode candidates (given code, UPC-A → EAN-13 by prepending `0`, EAN-13 → UPC-A by stripping leading `0`) against two Open Food Facts locales (`it` and default), with one automatic retry on network error. UPCItemDB fallback also iterates the same candidates. Reduces false negatives on 12-digit US barcodes and region-specific EAN codes.
|
||||
- **Partial throw from expired-items banner** — The "Butta" button in the dashboard banner previously discarded the entire inventory row with no confirmation. It now opens the existing throw modal (location picker + quantity input + "Butta tutto" option), consistent with the throw flow available from the scan → action page.
|
||||
|
||||
### Added
|
||||
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family (matching first name token or shopping name, different product ID) already at home, so you avoid double-buying.
|
||||
|
||||
## [1.7.24] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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)
|
||||
|
||||
+92
-98
@@ -1442,102 +1442,67 @@ function searchBarcode(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
function lookupBarcode(): void {
|
||||
$barcode = $_GET['barcode'] ?? '';
|
||||
if (empty($barcode)) {
|
||||
EverLog::info('lookupBarcode');
|
||||
echo json_encode(['found' => false, 'error' => 'No barcode provided']);
|
||||
return;
|
||||
function _offFetchProduct(string $barcode): ?array {
|
||||
$fields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores';
|
||||
|
||||
// Try candidate barcodes: given barcode + EAN-13 (UPC-A → prepend 0)
|
||||
$candidates = [$barcode];
|
||||
if (strlen($barcode) === 12 && ctype_digit($barcode)) {
|
||||
$candidates[] = '0' . $barcode;
|
||||
}
|
||||
// Also try without leading zero if 13 digits starting with 0
|
||||
if (strlen($barcode) === 13 && $barcode[0] === '0') {
|
||||
$candidates[] = substr($barcode, 1);
|
||||
}
|
||||
|
||||
// Try Open Food Facts API (Italian version first for better localized data)
|
||||
$url = "https://world.openfoodfacts.org/api/v2/product/{$barcode}.json?fields=product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores&lc=it";
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 10,
|
||||
'header' => "User-Agent: DispensaManager/1.0\r\n"
|
||||
]
|
||||
]);
|
||||
// Locale preference: Italian first (better names), then world-neutral
|
||||
$locales = ['lc=it', ''];
|
||||
|
||||
foreach ($candidates as $bc) {
|
||||
foreach ($locales as $lc) {
|
||||
$lcParam = $lc ? "&{$lc}" : '';
|
||||
$url = "https://world.openfoodfacts.org/api/v2/product/{$bc}.json?fields={$fields}{$lcParam}";
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 8, 'header' => "User-Agent: EverShelf/1.0\r\n"]]);
|
||||
|
||||
$response = @file_get_contents($url, false, $ctx);
|
||||
if ($response === false) {
|
||||
echo json_encode(['found' => false, 'source' => 'openfoodfacts', 'error' => 'API request failed']);
|
||||
return;
|
||||
// Network error: retry once after short delay
|
||||
usleep(300000); // 0.3s
|
||||
$response = @file_get_contents($url, false, $ctx);
|
||||
}
|
||||
if ($response === false) continue;
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (isset($data['status']) && $data['status'] === 1 && !empty($data['product'])) {
|
||||
if (!isset($data['status']) || $data['status'] !== 1 || empty($data['product'])) continue;
|
||||
|
||||
$p = $data['product'];
|
||||
|
||||
// Prefer Italian name, fall back to generic
|
||||
// Also request localized name via abbreviated_product_name
|
||||
// Prefer Italian name, fall back to generic / any locale
|
||||
$name = '';
|
||||
if (!empty($p['product_name_it'])) {
|
||||
$name = $p['product_name_it'];
|
||||
} elseif (!empty($p['generic_name_it'])) {
|
||||
$name = $p['generic_name_it'];
|
||||
} elseif (!empty($p['product_name'])) {
|
||||
$name = $p['product_name'];
|
||||
} elseif (!empty($p['generic_name'])) {
|
||||
$name = $p['generic_name'];
|
||||
foreach (['product_name_it', 'generic_name_it', 'product_name', 'generic_name'] as $f) {
|
||||
if (!empty($p[$f])) { $name = $p[$f]; break; }
|
||||
}
|
||||
|
||||
// If the name looks like it's in a non-Latin script (Arabic, Chinese, Thai, etc.)
|
||||
// try to use a fallback from brands + generic category
|
||||
// Non-Latin script fallback
|
||||
if (!empty($name) && preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $name)) {
|
||||
// Try other name fields that might be in Latin script
|
||||
$latinName = '';
|
||||
foreach (['generic_name_it', 'generic_name', 'product_name_it', 'product_name'] as $field) {
|
||||
if (!empty($p[$field]) && !preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $p[$field])) {
|
||||
$latinName = $p[$field];
|
||||
break;
|
||||
foreach (['generic_name_it', 'generic_name', 'product_name_it', 'product_name'] as $f) {
|
||||
if (!empty($p[$f]) && !preg_match('/[\x{0600}-\x{06FF}\x{0E00}-\x{0E7F}\x{4E00}-\x{9FFF}\x{3040}-\x{30FF}\x{AC00}-\x{D7AF}\x{0400}-\x{04FF}]/u', $p[$f])) {
|
||||
$latinName = $p[$f]; break;
|
||||
}
|
||||
}
|
||||
// If still no Latin name, construct from brand + category
|
||||
if (empty($latinName)) {
|
||||
$brand = $p['brands'] ?? '';
|
||||
$latinName = !empty($brand) ? $brand : 'Prodotto sconosciuto';
|
||||
}
|
||||
if (empty($latinName)) $latinName = !empty($p['brands']) ? $p['brands'] : 'Prodotto sconosciuto';
|
||||
$name = $latinName;
|
||||
}
|
||||
|
||||
// Get Italian ingredients, fall back to generic
|
||||
$ingredients = '';
|
||||
if (!empty($p['ingredients_text_it'])) {
|
||||
$ingredients = $p['ingredients_text_it'];
|
||||
} elseif (!empty($p['ingredients_text'])) {
|
||||
$ingredients = $p['ingredients_text'];
|
||||
}
|
||||
|
||||
// Category: prefer Italian categories_tags, fallback
|
||||
$category = '';
|
||||
if (!empty($p['categories_tags'])) {
|
||||
// Try to find an Italian-friendly category
|
||||
$category = $p['categories_tags'][0] ?? '';
|
||||
} elseif (!empty($p['categories_hierarchy'])) {
|
||||
$category = end($p['categories_hierarchy']);
|
||||
} elseif (!empty($p['categories'])) {
|
||||
$category = $p['categories'];
|
||||
}
|
||||
|
||||
// Allergens
|
||||
$ingredients = $p['ingredients_text_it'] ?? $p['ingredients_text'] ?? '';
|
||||
$category = $p['categories_tags'][0] ?? end($p['categories_hierarchy'] ?? []) ?? $p['categories'] ?? '';
|
||||
$allergens = '';
|
||||
if (!empty($p['allergens_tags'])) {
|
||||
$allergens = implode(', ', array_map(function($a) {
|
||||
return str_replace('en:', '', $a);
|
||||
}, $p['allergens_tags']));
|
||||
$allergens = implode(', ', array_map(fn($a) => str_replace('en:', '', $a), $p['allergens_tags']));
|
||||
}
|
||||
|
||||
// Conservation / storage
|
||||
$conservation = $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? '';
|
||||
|
||||
// Origin
|
||||
$origin = $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? '';
|
||||
|
||||
$result = [
|
||||
'found' => true,
|
||||
'source' => 'openfoodfacts',
|
||||
'product' => [
|
||||
return [
|
||||
'name' => $name,
|
||||
'brand' => $p['brands'] ?? '',
|
||||
'category' => $category,
|
||||
@@ -1546,44 +1511,56 @@ function lookupBarcode(): void {
|
||||
'nutriscore' => $p['nutriscore_grade'] ?? '',
|
||||
'ingredients' => $ingredients,
|
||||
'allergens' => $allergens,
|
||||
'conservation' => $conservation,
|
||||
'origin' => $origin,
|
||||
'conservation' => $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? '',
|
||||
'origin' => $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? '',
|
||||
'nova_group' => $p['nova_group'] ?? '',
|
||||
'ecoscore' => $p['ecoscore_grade'] ?? '',
|
||||
'labels' => $p['labels'] ?? '',
|
||||
'stores' => $p['stores'] ?? '',
|
||||
]
|
||||
];
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
// Try UPC Item DB as fallback
|
||||
$url2 = "https://api.upcitemdb.com/prod/trial/lookup?upc={$barcode}";
|
||||
$ctx2 = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 10,
|
||||
'header' => "User-Agent: DispensaManager/1.0\r\n"
|
||||
]
|
||||
]);
|
||||
$response2 = @file_get_contents($url2, false, $ctx2);
|
||||
if ($response2 !== false) {
|
||||
$data2 = json_decode($response2, true);
|
||||
if (!empty($data2['items'][0])) {
|
||||
$item = $data2['items'][0];
|
||||
echo json_encode([
|
||||
'found' => true,
|
||||
'source' => 'upcitemdb',
|
||||
'product' => [
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupBarcode(): void {
|
||||
$barcode = $_GET['barcode'] ?? '';
|
||||
if (empty($barcode)) {
|
||||
EverLog::info('lookupBarcode');
|
||||
echo json_encode(['found' => false, 'error' => 'No barcode provided']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Try Open Food Facts (multi-barcode, multi-locale, with auto-retry on network errors)
|
||||
$offProduct = _offFetchProduct($barcode);
|
||||
if ($offProduct !== null) {
|
||||
echo json_encode(['found' => true, 'source' => 'openfoodfacts', 'product' => $offProduct]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Try UPC Item DB as fallback
|
||||
$candidates = [$barcode];
|
||||
if (strlen($barcode) === 12 && ctype_digit($barcode)) $candidates[] = '0' . $barcode;
|
||||
foreach ($candidates as $bc) {
|
||||
$url2 = "https://api.upcitemdb.com/prod/trial/lookup?upc={$bc}";
|
||||
$ctx2 = stream_context_create(['http' => ['timeout' => 8, 'header' => "User-Agent: EverShelf/1.0\r\n"]]);
|
||||
$r2 = @file_get_contents($url2, false, $ctx2);
|
||||
if ($r2 !== false) {
|
||||
$d2 = json_decode($r2, true);
|
||||
if (!empty($d2['items'][0])) {
|
||||
$item = $d2['items'][0];
|
||||
echo json_encode(['found' => true, 'source' => 'upcitemdb', 'product' => [
|
||||
'name' => $item['title'] ?? '',
|
||||
'brand' => $item['brand'] ?? '',
|
||||
'category' => $item['category'] ?? '',
|
||||
'image_url' => $item['images'][0] ?? '',
|
||||
]
|
||||
]);
|
||||
]]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
echo json_encode(['found' => false, 'source' => 'openfoodfacts']);
|
||||
}
|
||||
|
||||
echo json_encode(['found' => false, 'source' => 'openfoodfacts']);
|
||||
}
|
||||
|
||||
function saveProduct(PDO $db): void {
|
||||
@@ -9526,13 +9503,30 @@ function _calcEstimatedTotal(float $pricePerUnit, string $priceUnitLabel, float
|
||||
} elseif (($unit === 'conf' || $unit === 'pz') && $defQty > 0 && empty($pkgUnit)) {
|
||||
// pkgUnit not recorded in DB — for /kg prices assume defQty is in grams
|
||||
// (vast majority of grocery packages: pancetta 80g, formaggio 200g, etc.)
|
||||
// GUARD: if defQty < 20 it is almost certainly a piece/unit count (e.g. "1 pz
|
||||
// per purchase"), not a gram weight. Treating 1 as 1g would give a nonsense
|
||||
// price (e.g. Peperoni defQty=1 → 0.001 kg → €0.003 displayed as €0.00).
|
||||
// Skip the weight conversion for these; the item falls through to the
|
||||
// countable path at the bottom (ppu × qty) which returns a rough estimate.
|
||||
if ($defQty >= 20) {
|
||||
$weightKg = $qty * $defQty / 1000.0;
|
||||
}
|
||||
} elseif ($unit === 'g') {
|
||||
$weightKg = $qty / 1000.0;
|
||||
} elseif ($unit === 'kg') {
|
||||
$weightKg = $qty;
|
||||
}
|
||||
if ($weightKg <= 0) return null;
|
||||
if ($weightKg <= 0) {
|
||||
// Two cases:
|
||||
// A) defQty was 0 (no weight data at all) → "–" is more honest than a fake price.
|
||||
// B) defQty was 1-19 (suspicious: the value was stored as a piece count, not grams;
|
||||
// the assignment was intentionally skipped by the defQty<20 guard above).
|
||||
// In case B, fall back to ppu × qty so the badge shows something rather than €0.00.
|
||||
if (in_array($unit, ['pz', 'conf']) && $defQty > 0) {
|
||||
return round($pricePerUnit * max(1.0, $qty), 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return round($pricePerUnit * $weightKg, 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -5658,6 +5658,26 @@ body.cooking-mode-active .app-header {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Related stock hint (same generic family, different brand/product) */
|
||||
.action-related-stock-card {
|
||||
background: #f0fdf4;
|
||||
border: 1.5px solid #86efac;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.82rem;
|
||||
color: #166534;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.action-related-stock-card strong { color: #15803d; }
|
||||
.related-stock-item { display: inline-block; margin-right: 8px; }
|
||||
[data-theme="dark"] .action-related-stock-card {
|
||||
background: rgba(21, 128, 61, 0.12);
|
||||
border-color: #166534;
|
||||
color: #86efac;
|
||||
}
|
||||
[data-theme="dark"] .action-related-stock-card strong { color: #4ade80; }
|
||||
|
||||
/* ===== ACTION BUTTONS GRID ===== */
|
||||
.action-buttons-3col {
|
||||
display: grid;
|
||||
|
||||
+133
-49
@@ -4551,7 +4551,7 @@ function isSuspiciousQty(qty, unit) {
|
||||
function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) {
|
||||
const n = parseFloat(defaultQty);
|
||||
if (!n || n <= 0) return false;
|
||||
const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit;
|
||||
const checkUnit = ((unit === 'conf' || unit === 'pz') && packageUnit) ? packageUnit : unit;
|
||||
const th = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
|
||||
return n > th.max;
|
||||
}
|
||||
@@ -4750,24 +4750,30 @@ async function loadBannerAlerts() {
|
||||
});
|
||||
|
||||
// 7. Products with no expiry date set (and not permanently dismissed)
|
||||
// Warn for ALL food/drink items — only skip igiene/pulizia (non-food).
|
||||
// Items are capped at 8 per load (opened packages first) to avoid banner overflow.
|
||||
const noExpiryDismissed = _getNoExpiryDismissed();
|
||||
const PERISHABLE_CATS = ['latticini','carne','pesce','salumi','fresco','verdura','frutta','surgelati',
|
||||
'dairy','meat','fish','fresh','vegetables','fruit','frozen'];
|
||||
const NON_FOOD_CATS = ['igiene', 'pulizia'];
|
||||
const noExpiryItems = [];
|
||||
items.forEach(item => {
|
||||
if (_queuedItemIds.has(item.id)) return; // already in expired or review
|
||||
if (item.expiry_date) return; // already has expiry
|
||||
if (parseFloat(item.quantity) <= 0) return; // no stock
|
||||
const pid = String(item.product_id || item.id);
|
||||
if (noExpiryDismissed[pid]) return; // user said "no expiry needed"
|
||||
// Only flag perishable-looking categories or items with opened_at
|
||||
const cat = (item.category || '').toLowerCase();
|
||||
// Also infer category from name for items with missing/generic category
|
||||
const guessedCat = guessCategoryFromName(item.name || '');
|
||||
const perishableGuessed = ['latticini','carne','pesce','frutta','verdura','surgelati'].includes(guessedCat);
|
||||
const likelyPerishable = item.opened_at ||
|
||||
PERISHABLE_CATS.some(c => cat.includes(c)) ||
|
||||
perishableGuessed;
|
||||
if (!likelyPerishable) return;
|
||||
const cat = (item.category || '').toLowerCase();
|
||||
// Skip non-food categories
|
||||
if (NON_FOOD_CATS.includes(guessedCat) ||
|
||||
NON_FOOD_CATS.some(c => cat.includes(c))) return;
|
||||
noExpiryItems.push(item);
|
||||
});
|
||||
// Sort: opened packages first (more urgent), then alphabetically
|
||||
noExpiryItems.sort((a, b) => {
|
||||
if (!!a.opened_at !== !!b.opened_at) return a.opened_at ? -1 : 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
noExpiryItems.slice(0, 8).forEach(item => {
|
||||
_bannerQueue.push({ type: 'no_expiry', data: item });
|
||||
});
|
||||
|
||||
@@ -5196,19 +5202,18 @@ function bannerThrowAway() {
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
if (!entry) return;
|
||||
const item = entry.data;
|
||||
api('inventory_use', {}, 'POST', {
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity,
|
||||
location: item.location,
|
||||
use_all: true,
|
||||
notes: 'Buttato'
|
||||
}).then(res => {
|
||||
if (res.success) {
|
||||
showToast(t('toast.thrown_away', { name: item.name }), 'success');
|
||||
loadDashboard();
|
||||
}
|
||||
}).catch(() => showToast(t('error.connection'), 'error'));
|
||||
dismissBannerItem();
|
||||
// Populate currentProduct so the shared showThrowForm / throwAll / throwPartial work
|
||||
currentProduct = {
|
||||
id: item.product_id,
|
||||
name: item.name,
|
||||
brand: item.brand || '',
|
||||
image_url: item.image_url || null,
|
||||
category: item.category || '',
|
||||
unit: item.unit || 'pz',
|
||||
default_quantity: item.default_quantity || 0,
|
||||
package_unit: item.package_unit || ''
|
||||
};
|
||||
showThrowForm();
|
||||
}
|
||||
|
||||
async function bannerMarkVacuum() {
|
||||
@@ -7214,7 +7219,7 @@ function showProductAction() {
|
||||
}
|
||||
|
||||
// === CHECK INVENTORY FOR THIS PRODUCT ===
|
||||
checkInventoryForProduct(currentProduct.id).then(inventoryItems => {
|
||||
checkInventoryForProduct(currentProduct.id, currentProduct.name).then(({ items: inventoryItems, related: relatedItems }) => {
|
||||
_actionInventoryItems = inventoryItems;
|
||||
const statusBar = document.getElementById('action-inventory-status');
|
||||
const btnsContainer = document.getElementById('action-buttons-container');
|
||||
@@ -7302,6 +7307,31 @@ function showProductAction() {
|
||||
const orphan = document.getElementById('catalog-edit-link');
|
||||
if (orphan) orphan.remove();
|
||||
}
|
||||
|
||||
// === RELATED STOCK (same generic family, different product/brand) ===
|
||||
const relatedEl = document.getElementById('action-related-stock');
|
||||
if (relatedEl) {
|
||||
if (relatedItems.length > 0) {
|
||||
// Group by product name+brand and sum quantities
|
||||
const grouped = {};
|
||||
for (const ri of relatedItems) {
|
||||
const key = ri.product_id;
|
||||
if (!grouped[key]) grouped[key] = { item: ri, qty: 0 };
|
||||
grouped[key].qty += parseFloat(ri.quantity) || 0;
|
||||
}
|
||||
const parts = Object.values(grouped).map(({ item, qty }) => {
|
||||
const qtyStr = formatQuantity(qty, item.unit, item.default_quantity, item.package_unit);
|
||||
const locIcon = (LOCATIONS[item.location] || { icon: '📦' }).icon;
|
||||
const label = item.name + (item.brand ? ` (${item.brand})` : '');
|
||||
return `<span class="related-stock-item">${escapeHtml(label)}: <strong>${qtyStr}</strong> ${locIcon}</span>`;
|
||||
}).join('');
|
||||
relatedEl.innerHTML = `<div class="action-related-stock-card">🔍 ${t('action.related_stock_title')}: ${parts}</div>`;
|
||||
relatedEl.style.display = 'block';
|
||||
} else {
|
||||
relatedEl.style.display = 'none';
|
||||
relatedEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update back button: go back to shopping if came from shopping list scan
|
||||
@@ -7331,12 +7361,27 @@ function showProductAction() {
|
||||
}
|
||||
|
||||
// Check if product exists in inventory
|
||||
async function checkInventoryForProduct(productId) {
|
||||
async function checkInventoryForProduct(productId, productName) {
|
||||
try {
|
||||
const data = await api('inventory_list');
|
||||
return (data.inventory || []).filter(i => i.product_id == productId);
|
||||
const all = data.inventory || [];
|
||||
const exact = all.filter(i => i.product_id == productId);
|
||||
|
||||
// Find inventory items from the same generic family (same shopping_name or first token)
|
||||
const firstToken = (_nameTokens(productName || '')[0] || '').toLowerCase();
|
||||
const sNameFromExact = exact.length > 0 ? (exact[0].shopping_name || '').toLowerCase() : '';
|
||||
const matchToken = firstToken || sNameFromExact;
|
||||
const related = matchToken ? all.filter(i => {
|
||||
if (i.product_id == productId) return false;
|
||||
const iFirst = (_nameTokens(i.name || '')[0] || '').toLowerCase();
|
||||
const iSName = (i.shopping_name || '').toLowerCase();
|
||||
return iFirst === matchToken || iSName === matchToken ||
|
||||
(sNameFromExact && (iFirst === sNameFromExact || iSName === sNameFromExact));
|
||||
}) : [];
|
||||
|
||||
return { items: exact, related };
|
||||
} catch(e) {
|
||||
return [];
|
||||
return { items: [], related: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8815,12 +8860,12 @@ function selectUseLocation(btn, loc) {
|
||||
// ── PREFERRED USE LOCATION ───────────────────────────────────────────────
|
||||
// After 3+ consistent choices from the same location for a product,
|
||||
// auto-selects it and hides the location picker (user can still tap "cambia").
|
||||
const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference
|
||||
const _PREF_LOC_NEEDED = 1; // choices needed to confirm a preference
|
||||
|
||||
// ── PREFERRED MOVE-AFTER-USE LOCATION ────────────────────────────────────
|
||||
// Tracks where the user puts the remainder after using a product.
|
||||
// After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely.
|
||||
const _PREF_MOVE_NEEDED = 2;
|
||||
const _PREF_MOVE_NEEDED = 1;
|
||||
let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal
|
||||
|
||||
function _getMoveLocHistory(productId, fromLoc) {
|
||||
@@ -9215,6 +9260,14 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
|
||||
return;
|
||||
}
|
||||
|
||||
// If the product only exists at fromLoc (no other active locations), there is
|
||||
// nothing to move — auto-stay silently without showing the modal.
|
||||
const hasOtherLocs = (_useCurrentItems || []).some(i => i.location !== fromLoc);
|
||||
if (!hasOtherLocs) {
|
||||
_saveVacuumAndStay(openedId || 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
|
||||
const locButtons = otherLocs.map(([k, v]) =>
|
||||
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
|
||||
@@ -9231,7 +9284,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
|
||||
document.getElementById('modal-content').innerHTML = `
|
||||
<div class="modal-header">
|
||||
<h3>${t('move.title')}</h3>
|
||||
<button class="modal-close" onclick="clearMoveModalTimer();closeModal();showPage('dashboard')">✕</button>
|
||||
<button class="modal-close" onclick="clearMoveModalTimer();_saveVacuumAndStay(${openedId || 0})">✕</button>
|
||||
</div>
|
||||
<div style="padding:0 16px 16px">
|
||||
<p style="margin-bottom:12px">${t('move.question').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest')).replace('{name}', `<strong>${escapeHtml(product.name)}</strong>`)}</p>
|
||||
@@ -10175,6 +10228,7 @@ async function confirmShoppingItemFound() {
|
||||
try {
|
||||
const r = await api('shopping_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID });
|
||||
if (r.success) {
|
||||
_markBringPurchased([name]); // prevent background sync from re-adding before barcode scan
|
||||
const idx = shoppingItems.findIndex(i => i.name.toLowerCase() === name.toLowerCase());
|
||||
if (idx >= 0) shoppingItems.splice(idx, 1);
|
||||
showToast(t('shopping.item_removed').replace('{name}', name), 'success');
|
||||
@@ -10283,8 +10337,9 @@ async function autoAddCriticalItems() {
|
||||
if (i.on_bring) return false;
|
||||
// For imminent items, do not honor local "purchased" blocklist too aggressively.
|
||||
// If they are predicted to finish within a week, keep Bring aligned automatically.
|
||||
// Bypass blocklist for depleted items (current_qty=0) — they ran out and must be re-added
|
||||
if (!imminentWeek && (i.current_qty ?? 0) > 0 && _isBringPurchased(i.name, i.urgency)) return false;
|
||||
// Always honour the purchased blocklist so that items the user just removed from Bring!
|
||||
// (i.e. bought them at the store) are not immediately re-added before they are scanned.
|
||||
if (!imminentWeek && _isBringPurchased(i.name, i.urgency)) return false;
|
||||
if (i.urgency === 'critical') return true;
|
||||
if (i.urgency === 'high') return true;
|
||||
if (i.urgency === 'medium' && (i.days_left ?? 999) <= 7 && (i.uses_per_month || 0) >= 3) return true;
|
||||
@@ -10591,20 +10646,13 @@ async function cleanupObsoleteBringItems() {
|
||||
localStorage.setItem('_bringCleanupTs', String(Date.now()));
|
||||
if (!shoppingItems.length || !smartShoppingItems.length) return;
|
||||
|
||||
// Detect items added by the app vs manually by the user.
|
||||
// Items added by the app always have urgency markers in their spec (⚡ / 🟠 / 🛒).
|
||||
// This detection works across ALL clients — no localStorage dependency.
|
||||
const APP_SPEC_MARKERS = ['⚡', '🟠', '🛒'];
|
||||
const isAppAdded = (item) => {
|
||||
const spec = item.specification || '';
|
||||
// Also trust the legacy localStorage list as secondary signal
|
||||
const autoAdded = _getAutoAddedBring();
|
||||
const nameLow = item.name.toLowerCase();
|
||||
const hasMarker = APP_SPEC_MARKERS.some(m => spec.includes(m));
|
||||
const inLegacyMap = !!(autoAdded[nameLow] ||
|
||||
Object.keys(autoAdded).some(k => _nameTokens(k)[0] === (_nameTokens(item.name)[0] || '')));
|
||||
return hasMarker || inLegacyMap;
|
||||
};
|
||||
// Detect items added automatically by the app (via autoAddCriticalItems).
|
||||
// We rely ONLY on the explicit _autoAddedBring registry (exact name match).
|
||||
// Do NOT use spec markers: autoSyncUrgencySpecs stamps urgency markers (⚡/🟠/🛒)
|
||||
// on ALL matched items regardless of who added them, making marker-based detection
|
||||
// unreliable and causing accidental removal of user-added items.
|
||||
const _autoAdded = _getAutoAddedBring();
|
||||
const isAppAdded = (item) => !!(_autoAdded[item.name.toLowerCase()]);
|
||||
|
||||
// Build shopping_name family → total stock from smart_shopping (server already computed this)
|
||||
// If smart says a family is NOT needed, it already excluded them.
|
||||
@@ -10649,11 +10697,15 @@ async function cleanupObsoleteBringItems() {
|
||||
}
|
||||
|
||||
const toRemove = [];
|
||||
const _pinned = _pinnedBringCache || {};
|
||||
for (const item of shoppingItems) {
|
||||
const nameLower = item.name.toLowerCase();
|
||||
const itemToks = _nameTokens(item.name);
|
||||
const itemFirst = itemToks[0];
|
||||
|
||||
// Never remove items explicitly pinned by the user
|
||||
if (_pinned[nameLower]) continue;
|
||||
|
||||
// Only remove items the app put there
|
||||
if (!isAppAdded(item)) continue;
|
||||
|
||||
@@ -11587,6 +11639,7 @@ async function removeBringItem(idx) {
|
||||
listUUID: shoppingListUUID
|
||||
});
|
||||
if (data.success) {
|
||||
_markBringPurchased([item.name]); // prevent background sync from re-adding before barcode scan
|
||||
shoppingItems.splice(idx, 1);
|
||||
renderShoppingItems();
|
||||
showToast(t('toast.removed_from_list_short'), 'success');
|
||||
@@ -14646,6 +14699,29 @@ function saveChatHistory() {
|
||||
// ===== SCREENSAVER & INACTIVITY AUTO-REFRESH =====
|
||||
let _inactivityTimer = null;
|
||||
let _screensaverActive = false;
|
||||
|
||||
// ── Auto-home: always-on 2-minute idle return to dashboard ──
|
||||
let _autoHomeTimer = null;
|
||||
const AUTO_HOME_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
function _resetAutoHomeTimer() {
|
||||
clearTimeout(_autoHomeTimer);
|
||||
_autoHomeTimer = setTimeout(_triggerAutoHome, AUTO_HOME_MS);
|
||||
}
|
||||
|
||||
function _cancelAutoHomeTimer() {
|
||||
clearTimeout(_autoHomeTimer);
|
||||
_autoHomeTimer = null;
|
||||
}
|
||||
|
||||
function _triggerAutoHome() {
|
||||
if (_screensaverActive) return;
|
||||
if (document.body.classList.contains('cooking-mode-active')) return;
|
||||
if (_currentPageId === 'dashboard') return;
|
||||
const modal = document.getElementById('modal-overlay');
|
||||
if (modal && modal.style.display === 'flex') return;
|
||||
showPage('dashboard');
|
||||
}
|
||||
let _screensaverClockInterval = null;
|
||||
let _screensaverFactInterval = null;
|
||||
let _screensaverData = null; // cached data for fact generation
|
||||
@@ -14665,6 +14741,7 @@ function resetInactivityTimer() {
|
||||
function activateScreensaver() {
|
||||
if (_screensaverActive) return;
|
||||
if (document.body.classList.contains('cooking-mode-active')) return;
|
||||
_cancelAutoHomeTimer();
|
||||
_screensaverActive = true;
|
||||
const overlay = document.getElementById('screensaver');
|
||||
overlay.style.display = 'flex';
|
||||
@@ -14756,6 +14833,7 @@ function dismissScreensaver(targetPage) {
|
||||
refreshCurrentPage();
|
||||
}
|
||||
resetInactivityTimer();
|
||||
_resetAutoHomeTimer();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
@@ -15347,16 +15425,22 @@ function initInactivityWatcher() {
|
||||
const events = ['pointerdown', 'pointermove', 'keydown', 'scroll', 'touchstart'];
|
||||
events.forEach(evt => {
|
||||
document.addEventListener(evt, () => {
|
||||
if (!getSettings().screensaver_enabled) return;
|
||||
if (_screensaverActive) {
|
||||
dismissScreensaver();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Auto-home: always reset regardless of screensaver setting
|
||||
_resetAutoHomeTimer();
|
||||
// Screensaver: only if enabled
|
||||
if (getSettings().screensaver_enabled) {
|
||||
resetInactivityTimer();
|
||||
}
|
||||
}, { passive: true });
|
||||
});
|
||||
_inactivityListenersAttached = true;
|
||||
}
|
||||
// Always start auto-home timer; screensaver only if enabled
|
||||
_resetAutoHomeTimer();
|
||||
if (getSettings().screensaver_enabled) {
|
||||
resetInactivityTimer();
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
|
||||
<div class="product-preview product-preview-small" id="action-product-preview"></div>
|
||||
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
|
||||
<div id="action-related-stock" style="display:none"></div>
|
||||
<div class="action-buttons" id="action-buttons-container">
|
||||
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
||||
<span class="btn-icon">📥</span>
|
||||
|
||||
@@ -227,7 +227,8 @@
|
||||
"throw_btn": "🗑️ ENTSORGEN",
|
||||
"throw_sub": "wegwerfen",
|
||||
"edit_sub": "Ablauf, Ort…",
|
||||
"create_recipe_btn": "Rezept"
|
||||
"create_recipe_btn": "Rezept",
|
||||
"related_stock_title": "Auch zuhause"
|
||||
},
|
||||
"add": {
|
||||
"title": "Zum Vorrat hinzufügen",
|
||||
|
||||
@@ -227,7 +227,8 @@
|
||||
"throw_btn": "🗑️ DISCARD",
|
||||
"throw_sub": "throw away",
|
||||
"edit_sub": "expiry, location…",
|
||||
"create_recipe_btn": "Recipe"
|
||||
"create_recipe_btn": "Recipe",
|
||||
"related_stock_title": "Also at home"
|
||||
},
|
||||
"add": {
|
||||
"title": "Add to Pantry",
|
||||
|
||||
@@ -227,7 +227,8 @@
|
||||
"throw_btn": "🗑️ BUTTA",
|
||||
"throw_sub": "butta il prodotto",
|
||||
"edit_sub": "scadenza, luogo…",
|
||||
"create_recipe_btn": "Ricetta"
|
||||
"create_recipe_btn": "Ricetta",
|
||||
"related_stock_title": "Hai anche in casa"
|
||||
},
|
||||
"add": {
|
||||
"title": "Aggiungi alla Dispensa",
|
||||
|
||||
Reference in New Issue
Block a user