chore: auto-merge develop → main
Triggered by: f65fb43 fix: shopping list accuracy, Bring! cleanup server-side, vacuum prompt, recipe appliances
This commit is contained in:
+20
-1
@@ -5,7 +5,26 @@ All notable changes to EverShelf will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.4] - 2026-05-07
|
||||
## [1.7.5] - 2026-05-10
|
||||
|
||||
### Added
|
||||
- **Vacuum sealed prompt on item use** — After using a conf/weighted-unit item that still has remaining stock, a sliding popup asks "🔒 Messo sotto vuoto?" with Sì/No buttons and an 8-second auto-dismiss countdown bar. Default is Sì if the item was previously sealed, No otherwise. Works for all container units (conf, g, kg, ml, l) and any item previously marked as vacuum sealed.
|
||||
- **Multi-function appliance awareness in recipes** — When the user sets a multi-function appliance (Cookeo, Bimby, Thermomix, Monsieur Cuisine, Instant Pot, Multicooker, Robot da cucina) in Settings, all Gemini recipe prompts (chat, recipe generation, weekly meal plan) now explicitly instruct the AI to consolidate as many cooking steps as possible into that single machine. Each appliance's available functions (rosolare, tritare, vapore, cuocere a pressione, etc.) are listed and the AI is required to indicate the specific mode/program at each step.
|
||||
- **Server-side Bring! cleanup in cron** — `bringCleanupObsolete()` now runs every 5 minutes via cron without requiring any client page load. Items auto-added by the app (identified by `⚡`/`🟠`/`🛒` markers in their Bring! spec) are automatically removed when the smart shopping engine no longer flags them as needed. Works across all devices/clients.
|
||||
- **`shopping_name` in `inventory_list` API** — The `inventory_list` endpoint now returns the `shopping_name` field from the products table, enabling family-based stock matching in the client-side cleanup fallback.
|
||||
|
||||
### Fixed
|
||||
- **Bring! cleanup: false token match (Succo/Frutta)** — `bringCleanupObsolete` previously indexed smart items by product name tokens. "Pera Italiana **Succo** e polpa **frutta**" (shopping_name: "Pere") caused "Succo" and "Frutta" to be retained on Bring! indefinitely even when fully stocked. Now indexes **only** by `shopping_name` tokens.
|
||||
- **Bring! cleanup: expired items with fresh family stock (Verdure)** — When a product is expired but its `shopping_name` family has ≥50% fresh stock from other products (e.g. Minestrone tradizione scaduto 01/05 but 590g fresh Verdure in freezer/pantry), it is no longer flagged as `critical` and is removed from the shopping list.
|
||||
- **Bring! remove: catalog items not removed (Formaggio/Käse)** — `bringRemoveItem()` and `bringCleanupObsolete()` now try both the Italian display name and the Bring! internal German catalog key (e.g. `Käse` for `Formaggio`). Previously, catalog items with a German key were silently not removed.
|
||||
- **Barcode scanner: EAN auto-submit on manual input** — Typing or pasting a valid 8/13-digit EAN in the manual barcode field now auto-submits immediately without needing to press a button. Checksum validation gives a warning toast for invalid codes without blocking entry.
|
||||
- **Shopping list: `isExpiringSoon` false positives** — Products bought in bulk that expire naturally in 3 days (e.g. fresh produce) were flagged `medium` urgency on the shopping list despite having 100%+ stock. Now requires `pctLeft < 50%` before triggering.
|
||||
- **Shopping list: expired batch with fresh restock suppressed** — Products with an expired batch AND a recent fresh restock (≥50% fresh stock) are no longer flagged `critical` for shopping. The expired-batch UI banner on the dashboard handles the disposal prompt instead.
|
||||
- **Shopping list: cross-device cleanup** — Client-side `cleanupObsoleteBringItems()` now detects app-added items by their spec markers (`⚡`/`🟠`/`🛒`) instead of a per-device localStorage map, making cleanup work correctly on all clients including newly logged-in devices. Throttle reduced from 30 minutes to 3 minutes.
|
||||
- **API fetch caching disabled** — All `api()` calls in the frontend now set `cache: 'no-store'` to prevent stale data from browser cache.
|
||||
- **Shopping page multi-client sync** — Added 45-second polling on the shopping page so changes made on another device are reflected automatically.
|
||||
|
||||
|
||||
|
||||
### Added
|
||||
- **AI price estimation for shopping list** — Each item on the Bring! shopping list now shows an estimated retail price badge (per unit and total). Prices are fetched from Gemini AI and cached server-side for 3 months (`PRICE_UPDATE_MONTHS`). The running estimated total is displayed both in the shopping tab and as a green pill badge on the dashboard stat card.
|
||||
|
||||
@@ -39,7 +39,33 @@ try {
|
||||
throw new RuntimeException('Cannot write cache file: ' . CACHE_FILE);
|
||||
}
|
||||
|
||||
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . count($decoded['items'] ?? []) . " items cached\n";
|
||||
$itemCount = count($decoded['items'] ?? []);
|
||||
echo '[' . date('Y-m-d H:i:s') . '] OK — ' . $itemCount . " items cached\n";
|
||||
|
||||
// ── Bring! server-side cleanup ────────────────────────────────────────
|
||||
// After computing smart shopping, automatically remove stale Bring! items
|
||||
// and add/update critical ones. This runs fully server-side every cron cycle.
|
||||
try {
|
||||
$cleanupResult = bringCleanupObsolete($db);
|
||||
if (isset($cleanupResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup skipped: ' . $cleanupResult['skipped'] . "\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! cleanup — removed: ' . ($cleanupResult['removed'] ?? 0)
|
||||
. '/' . ($cleanupResult['candidates'] ?? 0) . ' candidates'
|
||||
. ($cleanupResult['errors'] ? ', errors: ' . $cleanupResult['errors'] : '') . "\n";
|
||||
}
|
||||
|
||||
$addResult = bringAutoAddCritical($db);
|
||||
if (isset($addResult['skipped'])) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add skipped: ' . $addResult['skipped'] . "\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! auto-add — added: ' . ($addResult['added'] ?? 0)
|
||||
. ', updated specs: ' . ($addResult['updated'] ?? 0) . "\n";
|
||||
}
|
||||
} catch (Throwable $be) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Bring! sync warning: ' . $be->getMessage() . "\n";
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$msg = $e->getMessage();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
||||
|
||||
+401
-39
@@ -909,7 +909,7 @@ function listInventory(PDO $db): void {
|
||||
$location = $_GET['location'] ?? '';
|
||||
$query = "
|
||||
SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode, p.default_quantity, p.package_unit,
|
||||
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at
|
||||
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at, p.shopping_name
|
||||
FROM inventory i
|
||||
JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0
|
||||
@@ -1417,7 +1417,16 @@ function useFromInventory(PDO $db): void {
|
||||
$shopping = $prodInfo['shopping_name'] ?: computeShoppingName($prodInfo['name'], '', $prodInfo['brand']);
|
||||
$response['product_shopping_name'] = $shopping;
|
||||
}
|
||||
if ($openedId) $response['opened_id'] = $openedId;
|
||||
if ($openedId) {
|
||||
$response['opened_id'] = $openedId;
|
||||
$response['opened_vacuum_sealed'] = (int)($existing['vacuum_sealed'] ?? 0);
|
||||
} elseif ($remaining > 0 && isset($existing['id'])) {
|
||||
// Fallback: for any partial use (including pz items) where no dedicated
|
||||
// "opened" row was created, still provide the row ID so the UI can ask
|
||||
// about vacuum sealing the remaining portion.
|
||||
$response['opened_id'] = (int)$existing['id'];
|
||||
$response['opened_vacuum_sealed'] = (int)($existing['vacuum_sealed'] ?? 0);
|
||||
}
|
||||
echo json_encode($response);
|
||||
// Inventory changed — force smart-shopping recompute on next request
|
||||
invalidateSmartShoppingCache();
|
||||
@@ -2023,7 +2032,7 @@ function getConsumptionPredictions(PDO $db): void {
|
||||
$lastIn = $db->prepare("
|
||||
SELECT quantity, created_at
|
||||
FROM transactions
|
||||
WHERE product_id = ? AND location = ? AND type = 'in'
|
||||
WHERE product_id = ? AND location = ? AND type = 'in' AND undone = 0
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
");
|
||||
@@ -2033,7 +2042,18 @@ function getConsumptionPredictions(PDO $db): void {
|
||||
if (!$restock) continue;
|
||||
|
||||
$restockDate = strtotime($restock['created_at']);
|
||||
$restockQty = floatval($restock['quantity']);
|
||||
|
||||
// Sum ALL 'in' transactions within 24h of the last restock (= one shopping session).
|
||||
// Using only the last single transaction as restockQty causes false positives when
|
||||
// the user scans multiple items separately (e.g. 3 mozzarelle one by one).
|
||||
$sessionIn = $db->prepare("
|
||||
SELECT SUM(quantity) as total
|
||||
FROM transactions
|
||||
WHERE product_id = ? AND location = ? AND type = 'in' AND undone = 0
|
||||
AND created_at >= datetime(?, '-24 hours')
|
||||
");
|
||||
$sessionIn->execute([$pid, $loc, $restock['created_at']]);
|
||||
$restockQty = floatval($sessionIn->fetchColumn() ?: $restock['quantity']);
|
||||
|
||||
// If inventory was manually edited (updated_at > last restock), use the
|
||||
// manual update as baseline instead — otherwise the prediction is comparing
|
||||
@@ -2727,10 +2747,7 @@ function geminiChat(PDO $db): void {
|
||||
}
|
||||
$ingredientsText = implode("\n", $ingredientLines);
|
||||
|
||||
$appliancesText = '';
|
||||
if (!empty($appliances)) {
|
||||
$appliancesText = "\nElettodomestici disponibili: " . implode(', ', $appliances) . " (più fornelli e forno sempre disponibili).";
|
||||
}
|
||||
$appliancesText = _buildAppliancesPrompt($appliances, compact: true);
|
||||
|
||||
$dietaryText = '';
|
||||
if (!empty($dietaryRestrictions)) {
|
||||
@@ -3125,11 +3142,8 @@ function generateRecipe(PDO $db): void {
|
||||
}
|
||||
|
||||
// Appliances
|
||||
$appliancesText = '';
|
||||
if (!empty($appliances)) {
|
||||
$appliancesText = "\n\nELETTRODOMESTICI: " . implode(', ', $appliances) . " (+ fornelli e forno). Usa SOLO questi.";
|
||||
}
|
||||
|
||||
$appliancesText = _buildAppliancesPrompt($appliances, compact: false);
|
||||
|
||||
// Dietary restrictions
|
||||
$dietaryText = '';
|
||||
if (!empty($dietaryRestrictions)) {
|
||||
@@ -3682,7 +3696,7 @@ function generateRecipeStream(PDO $db): void {
|
||||
$optionLabels = ['veloce'=>'VELOCE: max 15-20 min totali.','pocafame'=>'POCA FAME: porzione leggera, snack o insalata.','scadenze'=>'PRIORITÀ SCADENZE: usa per primi i prodotti in scadenza.','salutare'=>'SALUTARE: ingredienti integrali, verdure, pochi grassi.','opened'=>'PRIORITÀ APERTI: usa per primi i prodotti [APERTO].','zerowaste'=>'ZERO SPRECHI: usa il più possibile ingredienti in scadenza.'];
|
||||
foreach ($options as $opt) { if (isset($optionLabels[$opt])) $extraRules[] = $optionLabels[$opt]; }
|
||||
$extraRulesText = !empty($extraRules) ? "\n\nPREFERENZE DELL'UTENTE:\n" . implode("\n", $extraRules) : '';
|
||||
$appliancesText = !empty($appliances) ? "\n\nELETTRODOMESTICI: " . implode(', ', $appliances) . " (+ fornelli e forno). Usa SOLO questi." : '';
|
||||
$appliancesText = _buildAppliancesPrompt($appliances, compact: false);
|
||||
$dietaryText = !empty($dietaryRestrictions) ? "\n\nRESTRIZIONI ALIMENTARI:\n{$dietaryRestrictions}\nRispetta SEMPRE queste restrizioni." : '';
|
||||
|
||||
$mealPlanTypeLabels = ['pasta'=>'Pasta (primo piatto a base di pasta)','riso'=>'Riso (risotto, insalata di riso, riso saltato, ecc.)','carne'=>'Carne (secondo piatto a base di carne)','pesce'=>'Pesce (secondo piatto a base di pesce o frutti di mare)','legumi'=>'Legumi (zuppa, insalata, hummus, pasta e fagioli, ecc.)','uova'=>'Uova (frittata, uova strapazzate, quiche, ecc.)','formaggio'=>'Formaggio (fonduta, gnocchi al formaggio, torta salata, ecc.)','pizza'=>'Pizza o focaccia (impastata in casa o usi ingredienti simili)','affettati'=>'Affettati (tagliere misto, piadina, panino, ecc.)','verdure'=>'Verdure (piatto principale a base di verdure, contorno abbondante)','zuppa'=>'Zuppa o minestra (zuppe, vellutate, minestrone)','insalata'=>'Insalata (insalata mista, insalata di riso o pasta, poke)','pane'=>'Pane / Sandwich (toast, tramezzino, bruschette)','dolce'=>'Dolce o dessert','libero'=>''];
|
||||
@@ -4169,6 +4183,91 @@ function searchOpenFoodFacts(string $searchTerms, string $name, string $brand):
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a detailed appliances prompt fragment for Gemini recipe generation.
|
||||
*
|
||||
* For multi-function appliances (Cookeo, Bimby, Thermomix, Monsieur Cuisine, etc.)
|
||||
* the prompt explicitly instructs the AI to consolidate as many steps as possible
|
||||
* into that single machine rather than using multiple appliances or the stove.
|
||||
*
|
||||
* @param string[] $appliances List of appliance names from user settings.
|
||||
* @param bool $compact True = one-line format (chat); False = multi-line (recipe gen).
|
||||
*/
|
||||
function _buildAppliancesPrompt(array $appliances, bool $compact = false): string {
|
||||
if (empty($appliances)) return '';
|
||||
|
||||
// Multi-function all-in-one cookers: can sauté, boil, steam, pressure-cook, blend, etc.
|
||||
$multiFunction = [
|
||||
'cookeo', 'bimby', 'thermomix', 'monsieur cuisine',
|
||||
'bimby tm', 'vorwerk', 'instant pot', 'multicooker',
|
||||
'robot da cucina', 'robot cucina',
|
||||
];
|
||||
|
||||
$detectedMulti = [];
|
||||
foreach ($appliances as $a) {
|
||||
$aLow = mb_strtolower(trim($a));
|
||||
foreach ($multiFunction as $kw) {
|
||||
if (str_contains($aLow, $kw)) {
|
||||
$detectedMulti[] = $a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allList = implode(', ', $appliances);
|
||||
|
||||
if (empty($detectedMulti)) {
|
||||
// No multi-function appliance: standard wording
|
||||
return $compact
|
||||
? "\nElettrodomestici disponibili: {$allList} (più fornelli e forno sempre disponibili)."
|
||||
: "\n\nELETTRODOMESTICI: {$allList} (+ fornelli e forno). Usa SOLO questi.";
|
||||
}
|
||||
|
||||
// Build capability hint per multi-function appliance
|
||||
$capabilityMap = [
|
||||
'cookeo' => 'rosolare, stufare, cuocere a pressione, vapore, saltare, riscaldare',
|
||||
'bimby' => 'tritare, frullare, cuocere, soffriggere, vapore, impastare, pesare, emulsionare',
|
||||
'thermomix' => 'tritare, frullare, cuocere, soffriggere, vapore, impastare, pesare, emulsionare',
|
||||
'monsieur cuisine' => 'tritare, frullare, cuocere, soffriggere, vapore, impastare, pesare',
|
||||
'instant pot' => 'rosolare, cuocere a pressione, stufare, vapore, slow cook, riscaldare',
|
||||
'multicooker' => 'rosolare, cuocere a pressione, stufare, vapore, slow cook',
|
||||
'robot da cucina' => 'tritare, frullare, cuocere, mescolare, impastare',
|
||||
'robot cucina' => 'tritare, frullare, cuocere, mescolare, impastare',
|
||||
];
|
||||
|
||||
$multiDetails = [];
|
||||
foreach ($detectedMulti as $a) {
|
||||
$aLow = mb_strtolower(trim($a));
|
||||
$cap = '';
|
||||
foreach ($capabilityMap as $kw => $caps) {
|
||||
if (str_contains($aLow, $kw)) { $cap = $caps; break; }
|
||||
}
|
||||
$multiDetails[] = $cap ? "{$a} ({$cap})" : $a;
|
||||
}
|
||||
$multiStr = implode(' e ', $multiDetails);
|
||||
|
||||
// The other (non-multi) appliances available as backup
|
||||
$others = array_filter($appliances, fn($a) => !in_array($a, $detectedMulti));
|
||||
$othersStr = !empty($others) ? ', ' . implode(', ', $others) . ' (accessori di supporto se serve)' : '';
|
||||
|
||||
if ($compact) {
|
||||
return "\nElettrodomestici: {$allList}. PREFERISCI usare {$multiStr} per quanti più passaggi possibile.";
|
||||
}
|
||||
|
||||
$ruleLines = implode("\n", array_map(fn($d) => " → {$d}", $multiDetails));
|
||||
return <<<APPL
|
||||
|
||||
ELETTRODOMESTICI DISPONIBILI: {$allList} (+ fornelli e forno se indispensabile).
|
||||
⚠️ REGOLA OBBLIGATORIA APPARECCHI MULTIFUNZIONE:
|
||||
Hai a disposizione un apparecchio multifunzione potente. Devi usarlo per QUANTI PIÙ PASSI POSSIBILE.
|
||||
Funzioni disponibili:
|
||||
{$ruleLines}{$othersStr}
|
||||
→ Ogni passaggio che l'apparecchio può fare DA SOLO va fatto lì, NON su fornelli/forno separati.
|
||||
→ Indica esplicitamente nelle istruzioni quale funzione/programma usare (es. "modalità Rosolare", "Turbo 10 sec", "Varoma 20 min").
|
||||
→ Usa fornelli/forno SOLO per operazioni che l'apparecchio non supporta fisicamente.
|
||||
APPL;
|
||||
}
|
||||
|
||||
// ===== BRING! SHOPPING LIST INTEGRATION =====
|
||||
|
||||
function bringAuth(): ?array {
|
||||
@@ -4715,6 +4814,172 @@ function computeShoppingName(string $name, string $category = '', string $brand
|
||||
return ucfirst($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side Bring! cleanup: remove items from Bring! that the app auto-added
|
||||
* but are no longer flagged by smart shopping (stock is now adequate).
|
||||
* Called by the cron after recomputing the smart shopping cache.
|
||||
* Returns a summary array for logging.
|
||||
*/
|
||||
function bringCleanupObsolete(PDO $db): array {
|
||||
// Load the freshly-computed smart shopping cache
|
||||
$cacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
if (!file_exists($cacheFile)) return ['skipped' => 'no_cache'];
|
||||
$smartData = json_decode(file_get_contents($cacheFile), true);
|
||||
$smartItems = $smartData['items'] ?? [];
|
||||
|
||||
$auth = bringAuth();
|
||||
if (!$auth) return ['skipped' => 'no_bring_auth'];
|
||||
$listUUID = $auth['bringListUUID'];
|
||||
if (empty($listUUID)) return ['skipped' => 'no_list_uuid'];
|
||||
|
||||
$bringData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
|
||||
if (!$bringData || !isset($bringData['purchase'])) return ['skipped' => 'bring_fetch_failed'];
|
||||
|
||||
// Reuse nameTokens closure
|
||||
$stopwords = ['di','del','della','dei','il','la','le','lo','gli','un','una','e','con','per','da',
|
||||
'al','alla','in','su','se','che','non','ma','o','a','i','nel','nei','tra','delle',
|
||||
'degli','agli','dai','dalle','sui','sulle','sugli'];
|
||||
$ntFn = function(string $name) use ($stopwords): array {
|
||||
$name = mb_strtolower(trim($name));
|
||||
$toks = preg_split('/[^a-z0-9àáâãäåèéêëìíîïòóôõöùúûü]+/u', $name, -1, PREG_SPLIT_NO_EMPTY);
|
||||
return array_values(array_unique(array_filter($toks, fn($t) => mb_strlen($t) > 2 && !in_array($t, $stopwords))));
|
||||
};
|
||||
|
||||
// Build smart map: ONLY shopping_name tokens → item.
|
||||
// Deliberately NOT indexing by product name tokens — product names like
|
||||
// "Pera Italiana Succo e polpa frutta" contain words ("succo", "frutta") that
|
||||
// would wrongly keep unrelated Bring! items ("Succo", "Frutta") on the list.
|
||||
// The shopping_name (e.g. "Pere") is the canonical generic name used in Bring!.
|
||||
$smartByTok = [];
|
||||
foreach ($smartItems as $si) {
|
||||
$sName = !empty($si['shopping_name']) ? $si['shopping_name'] : $si['name'];
|
||||
foreach ($ntFn($sName) as $tok) {
|
||||
if (!isset($smartByTok[$tok])) $smartByTok[$tok] = $si;
|
||||
}
|
||||
}
|
||||
|
||||
// App-added marker: the app always writes ⚡ 🟠 or 🛒 in the specification
|
||||
$appMarkers = ['⚡', '🟠', '🛒'];
|
||||
|
||||
$toRemove = [];
|
||||
foreach ($bringData['purchase'] as $bringItem) {
|
||||
$spec = $bringItem['specification'] ?? '';
|
||||
$rawName = $bringItem['name'] ?? '';
|
||||
$name = bringToItalian($rawName);
|
||||
|
||||
// Only clean up items the app put there (identified by urgency markers in spec)
|
||||
$isAppAdded = false;
|
||||
foreach ($appMarkers as $m) {
|
||||
if (mb_strpos($spec, $m) !== false) { $isAppAdded = true; break; }
|
||||
}
|
||||
if (!$isAppAdded) continue;
|
||||
|
||||
// Match against smart items using shopping_name-priority tokens
|
||||
$nameToks = $ntFn($name);
|
||||
$firstTok = $nameToks[0] ?? '';
|
||||
$smartSi = $firstTok ? ($smartByTok[$firstTok] ?? null) : null;
|
||||
|
||||
if ($smartSi !== null) {
|
||||
// Still in smart_shopping with critical or high urgency → keep
|
||||
if (in_array($smartSi['urgency'], ['critical', 'high'], true)) continue;
|
||||
// Medium with low stock → keep
|
||||
if ($smartSi['urgency'] === 'medium' && (float)($smartSi['pct_left'] ?? 100) < 60) continue;
|
||||
// qty=0 → keep (genuinely out of stock)
|
||||
if ((float)($smartSi['current_qty'] ?? 0) <= 0) continue;
|
||||
}
|
||||
// Not in smart (or low-urgency with stock) → schedule for removal
|
||||
|
||||
$toRemove[] = ['name' => $name, 'rawName' => $rawName];
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
$errors = 0;
|
||||
foreach ($toRemove as $item) {
|
||||
// Try with the catalog key (rawName as returned from Bring! list)
|
||||
$body = http_build_query(['uuid' => $listUUID, 'remove' => $item['rawName']]);
|
||||
$result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
|
||||
|
||||
// Retry: if rawName is the Italian locale name, also try the German catalog key
|
||||
if ($result === null) {
|
||||
$catalogKey = italianToBring($item['name']);
|
||||
if ($catalogKey !== $item['rawName']) {
|
||||
$body = http_build_query(['uuid' => $listUUID, 'remove' => $catalogKey]);
|
||||
$result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
|
||||
}
|
||||
}
|
||||
|
||||
if ($result !== null) $removed++;
|
||||
else $errors++;
|
||||
}
|
||||
|
||||
return ['candidates' => count($toRemove), 'removed' => $removed, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side Bring! auto-add: push critical/high smart_shopping items to Bring!
|
||||
* that are not already on the list. Called by the cron alongside cleanup.
|
||||
*/
|
||||
function bringAutoAddCritical(PDO $db): array {
|
||||
$cacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
if (!file_exists($cacheFile)) return ['skipped' => 'no_cache'];
|
||||
$smartData = json_decode(file_get_contents($cacheFile), true);
|
||||
$smartItems = $smartData['items'] ?? [];
|
||||
|
||||
$auth = bringAuth();
|
||||
if (!$auth) return ['skipped' => 'no_bring_auth'];
|
||||
$listUUID = $auth['bringListUUID'];
|
||||
if (empty($listUUID)) return ['skipped' => 'no_list_uuid'];
|
||||
|
||||
$bringData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
|
||||
if (!$bringData || !isset($bringData['purchase'])) return ['skipped' => 'bring_fetch_failed'];
|
||||
|
||||
// Build set of already-present items (by Bring! key)
|
||||
$onBring = [];
|
||||
foreach ($bringData['purchase'] as $bi) {
|
||||
$onBring[strtolower($bi['name'] ?? '')] = true;
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
$updated = 0;
|
||||
foreach ($smartItems as $si) {
|
||||
if (!in_array($si['urgency'], ['critical', 'high'], true)) continue;
|
||||
|
||||
$genericName = $si['shopping_name'] ?: $si['name'];
|
||||
$bringName = italianToBring($genericName);
|
||||
$bringKey = strtolower($bringName);
|
||||
|
||||
// Build urgency spec
|
||||
$urgencyLabel = $si['urgency'] === 'critical' ? '⚡ Urgente' : '🟠 Presto';
|
||||
$spec = $urgencyLabel;
|
||||
if (!empty($si['name']) && $si['name'] !== $genericName) {
|
||||
$spec = $si['name'] . ($si['brand'] ? ' · ' . $si['brand'] : '') . ' — ' . $urgencyLabel;
|
||||
}
|
||||
if (!empty($si['suggested_qty'])) {
|
||||
$spec .= ' · 🛒 ' . ($si['qty_label'] ?? 'Almeno: ' . $si['suggested_qty'] . ' ' . ($si['unit'] ?? 'pz'));
|
||||
}
|
||||
|
||||
if (isset($onBring[$bringKey])) {
|
||||
// Update spec if it changed
|
||||
$existingSpec = '';
|
||||
foreach ($bringData['purchase'] as $bi) {
|
||||
if (strtolower($bi['name'] ?? '') === $bringKey) { $existingSpec = $bi['specification'] ?? ''; break; }
|
||||
}
|
||||
if ($existingSpec !== $spec) {
|
||||
$body = http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec]);
|
||||
bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
|
||||
$updated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$body = http_build_query(['uuid' => $listUUID, 'purchase' => $bringName, 'specification' => $spec]);
|
||||
$r = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
|
||||
if ($r !== null) $added++;
|
||||
}
|
||||
|
||||
return ['added' => $added, 'updated' => $updated];
|
||||
}
|
||||
|
||||
function bringGetList(): void {
|
||||
$auth = bringAuth();
|
||||
if (!$auth) {
|
||||
@@ -4885,16 +5150,29 @@ function bringRemoveItem(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use rawName (German key) if provided, otherwise try to map
|
||||
$rawName = $input['rawName'] ?? '';
|
||||
$removeName = !empty($rawName) ? $rawName : italianToBring($name);
|
||||
|
||||
$body = http_build_query([
|
||||
'uuid' => $listUUID,
|
||||
'remove' => $removeName,
|
||||
]);
|
||||
|
||||
// Use rawName (German catalog key) if provided, otherwise derive from Italian name.
|
||||
// Always try both the catalog key AND the Italian name as-stored, because:
|
||||
// – Catalog items: Bring! stores them internally by German key (e.g. "Käse" for "Formaggio")
|
||||
// but the list API returns them in the user's locale ("Formaggio").
|
||||
// Removal only works with the German key.
|
||||
// – Custom items (not in catalog): stored and removed by the name as entered.
|
||||
$rawName = $input['rawName'] ?? '';
|
||||
$catalogKey = italianToBring($name); // German key from catalog (may equal $name if not found)
|
||||
$removeName = !empty($rawName) ? $rawName : $catalogKey;
|
||||
|
||||
$listUUID = $auth['bringListUUID'];
|
||||
|
||||
// Try primary removal (catalog key or provided rawName)
|
||||
$body = http_build_query(['uuid' => $listUUID, 'remove' => $removeName]);
|
||||
$result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
|
||||
|
||||
// If the primary key was the catalog key and failed, retry with the Italian name as-is
|
||||
// (for custom non-catalog items stored under their Italian name)
|
||||
if ($result === null && $removeName !== $name) {
|
||||
$body = http_build_query(['uuid' => $listUUID, 'remove' => $name]);
|
||||
$result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
|
||||
}
|
||||
|
||||
if ($result !== null) {
|
||||
// Invalidate cache so next smart_shopping request reflects the updated Bring! list
|
||||
@unlink(__DIR__ . '/../data/smart_shopping_cache.json');
|
||||
@@ -5043,6 +5321,11 @@ function invalidateSmartShoppingCache(): void {
|
||||
}
|
||||
|
||||
function smartShoppingCached(PDO $db): void {
|
||||
// Never let the browser or proxy cache this — urgency is time-sensitive
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
|
||||
$cacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
$maxAge = 3 * 60; // 3 minutes — keep urgency fresh
|
||||
|
||||
@@ -5118,11 +5401,22 @@ function smartShopping(PDO $db): void {
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// Helper: extract significant tokens from a product name (mirrors JS _nameTokens)
|
||||
// Includes synonym expansion so French/Italian variants match (e.g. yaourt = yogurt)
|
||||
$nameTokens = function(string $name): array {
|
||||
$stop = ['di','del','della','dei','degli','delle','da','in','con','per','su',
|
||||
'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli','allo'];
|
||||
$synonyms = [
|
||||
'yaourt' => 'yogurt', 'yogourt' => 'yogurt',
|
||||
'lait' => 'latte', 'fromage' => 'formaggio',
|
||||
'sucre' => 'zucchero', 'jus' => 'succo',
|
||||
'orange' => 'arancia', 'pomme' => 'mela',
|
||||
'poire' => 'pera',
|
||||
];
|
||||
$tokens = preg_split('/\s+/', strtolower(preg_replace('/[^\p{L}\s]/u', ' ', $name)));
|
||||
return array_values(array_filter($tokens, fn($t) => strlen($t) > 2 && !in_array($t, $stop)));
|
||||
$tokens = array_filter($tokens, fn($t) => strlen($t) > 2 && !in_array($t, $stop));
|
||||
// Apply synonyms
|
||||
$tokens = array_map(fn($t) => $synonyms[$t] ?? $t, $tokens);
|
||||
return array_values(array_unique($tokens));
|
||||
};
|
||||
|
||||
// 1. Get all products with their inventory and transaction history
|
||||
@@ -5138,7 +5432,8 @@ function smartShopping(PDO $db): void {
|
||||
SELECT i.product_id, SUM(i.quantity) as total_qty,
|
||||
MIN(i.expiry_date) as nearest_expiry,
|
||||
GROUP_CONCAT(DISTINCT i.location) as locations,
|
||||
MAX(i.opened_at) as opened_at
|
||||
MAX(i.opened_at) as opened_at,
|
||||
SUM(CASE WHEN i.expiry_date IS NULL OR i.expiry_date >= date('now') THEN i.quantity ELSE 0 END) as fresh_qty
|
||||
FROM inventory i
|
||||
WHERE i.quantity > 0
|
||||
GROUP BY i.product_id
|
||||
@@ -5148,16 +5443,16 @@ function smartShopping(PDO $db): void {
|
||||
$inventory[$inv['product_id']] = $inv;
|
||||
}
|
||||
|
||||
// 3. Get transaction stats per product
|
||||
// 3. Get transaction stats per product (exclude undone=1 corrections)
|
||||
$txStmt = $db->query("
|
||||
SELECT product_id,
|
||||
COUNT(CASE WHEN type IN ('out','waste') THEN 1 END) as use_count,
|
||||
SUM(CASE WHEN type IN ('out','waste') THEN quantity ELSE 0 END) as total_used,
|
||||
COUNT(CASE WHEN type = 'in' THEN 1 END) as buy_count,
|
||||
SUM(CASE WHEN type = 'in' THEN quantity ELSE 0 END) as total_bought,
|
||||
MIN(CASE WHEN type = 'in' THEN created_at END) as first_in,
|
||||
MAX(CASE WHEN type = 'in' THEN created_at END) as last_in,
|
||||
MAX(CASE WHEN type IN ('out','waste') THEN created_at END) as last_out
|
||||
COUNT(CASE WHEN type IN ('out','waste') AND undone=0 THEN 1 END) as use_count,
|
||||
SUM(CASE WHEN type IN ('out','waste') AND undone=0 THEN quantity ELSE 0 END) as total_used,
|
||||
COUNT(CASE WHEN type = 'in' AND undone=0 THEN 1 END) as buy_count,
|
||||
SUM(CASE WHEN type = 'in' AND undone=0 THEN quantity ELSE 0 END) as total_bought,
|
||||
MIN(CASE WHEN type = 'in' AND undone=0 THEN created_at END) as first_in,
|
||||
MAX(CASE WHEN type = 'in' AND undone=0 THEN created_at END) as last_in,
|
||||
MAX(CASE WHEN type IN ('out','waste') AND undone=0 THEN created_at END) as last_out
|
||||
FROM transactions
|
||||
GROUP BY product_id
|
||||
");
|
||||
@@ -5188,12 +5483,24 @@ function smartShopping(PDO $db): void {
|
||||
// 'Aglio rosso' + 'Aglio' share 'aglio'
|
||||
// 'Latte di Montagna' + 'Latte Parzialmente Scremato' share 'latte'
|
||||
$stockByAnyToken = [];
|
||||
// Also build stockByShoppingName: normalized generic name → total qty.
|
||||
// And freshStockByShoppingName: same but only counting non-expired batches.
|
||||
$stockByShoppingName = [];
|
||||
$freshStockByShoppingName = [];
|
||||
foreach ($products as $pStock) {
|
||||
$qty = isset($inventory[$pStock['id']]) ? (float)$inventory[$pStock['id']]['total_qty'] : 0;
|
||||
if ($qty <= 0) continue;
|
||||
foreach ($nameTokens($pStock['name']) as $tok) {
|
||||
$stockByAnyToken[$tok] = ($stockByAnyToken[$tok] ?? 0) + $qty;
|
||||
}
|
||||
$sName = strtolower(trim($pStock['shopping_name'] ?? ''));
|
||||
if ($sName !== '') {
|
||||
$stockByShoppingName[$sName] = ($stockByShoppingName[$sName] ?? 0) + $qty;
|
||||
$fQty = isset($inventory[$pStock['id']]) ? (float)($inventory[$pStock['id']]['fresh_qty'] ?? $qty) : 0;
|
||||
if ($fQty > 0) {
|
||||
$freshStockByShoppingName[$sName] = ($freshStockByShoppingName[$sName] ?? 0) + $fQty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Analyze each product
|
||||
@@ -5235,6 +5542,9 @@ function smartShopping(PDO $db): void {
|
||||
$isExpired = $daysToExpiry < 0;
|
||||
$isExpiringSoon = !$isExpired && $daysToExpiry <= 3;
|
||||
|
||||
// Fresh (non-expired) quantity — used for suppression when only part of stock is expired
|
||||
$freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0;
|
||||
|
||||
// --- Stock level assessment ---
|
||||
// percentage_left: how much is left vs typical purchase size
|
||||
// Use average of totalBought/buyCount if available, else default_quantity, else best-guess from defQty or 1
|
||||
@@ -5242,6 +5552,8 @@ function smartShopping(PDO $db): void {
|
||||
? $totalBought / $buyCount
|
||||
: ($defQty > 0 ? $defQty : max(1, $qty)); // avoid inflating pctLeft for products with no history
|
||||
$pctLeft = $refQty > 0 ? min(200, ($qty / $refQty) * 100) : ($qty > 0 ? 100 : 0);
|
||||
// pctLeft based on FRESH (non-expired) stock only — used for expiry-aware suppression
|
||||
$freshPctLeft = $refQty > 0 ? min(200, ($freshQty / $refQty) * 100) : ($freshQty > 0 ? 100 : 0);
|
||||
|
||||
// Cap daysLeft at a reasonable ceiling to avoid 999-day noise in reason strings
|
||||
$daysLeft = min($daysLeft, 365);
|
||||
@@ -5286,6 +5598,16 @@ function smartShopping(PDO $db): void {
|
||||
foreach ($pToks as $tok) {
|
||||
if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; }
|
||||
}
|
||||
// Also check shopping_name coverage: if this depleted product has a generic name
|
||||
// (e.g. "Formaggio") and there's stock of ANY product with the same generic name,
|
||||
// the need is covered. This catches "Bel Paese" → covered by "Formaggio Gouda" in stock,
|
||||
// "Biscotti Pastefrolle" → covered by "Frollini..." (both shopping_name="Biscotti"), etc.
|
||||
if (!$coveredByEquivalent) {
|
||||
$sName = strtolower(trim($p['shopping_name'] ?? ''));
|
||||
if ($sName !== '' && ($stockByShoppingName[$sName] ?? 0) > 0) {
|
||||
$coveredByEquivalent = true;
|
||||
}
|
||||
}
|
||||
if ($coveredByEquivalent) continue;
|
||||
|
||||
if ($isFrequent && $isRecent && $buyCount >= 2) {
|
||||
@@ -5344,10 +5666,26 @@ function smartShopping(PDO $db): void {
|
||||
|
||||
// Expiring soon or expired (needs replacement) — valid regardless of frequency
|
||||
if ($isExpired && $qty > 0) {
|
||||
$urgency = 'critical';
|
||||
$reasons[] = 'Scaduto!';
|
||||
$score += 90;
|
||||
} elseif ($isExpiringSoon && $qty > 0) {
|
||||
// Check if the product's shopping_name FAMILY has adequate FRESH stock
|
||||
// from other (non-expired) products. If so, no need to buy more.
|
||||
$sNameKey = strtolower(trim($p['shopping_name'] ?? ''));
|
||||
$familyFreshQty = $sNameKey !== '' ? ($freshStockByShoppingName[$sNameKey] ?? 0) : 0;
|
||||
// Subtract this product's own qty (it is expired, so fresh_qty=0 for it anyway)
|
||||
$refQtyLocal = $refQty > 0 ? $refQty : 1;
|
||||
$familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100);
|
||||
|
||||
if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) {
|
||||
// Fresh stock from this product or same-family products is adequate.
|
||||
// The expired batch will show in the dashboard expiry banner — don't add to shopping list.
|
||||
} else {
|
||||
$urgency = 'critical';
|
||||
$reasons[] = 'Scaduto!';
|
||||
$score += 90;
|
||||
}
|
||||
} elseif ($isExpiringSoon && $qty > 0 && $pctLeft < 50) {
|
||||
// Only flag "expiring soon" if stock is also low (<50%). If you have plenty of
|
||||
// stock (e.g. just bought fresh produce that naturally expires in 3 days), the
|
||||
// shopping list is not the right place — the expiry banner handles it.
|
||||
if ($urgency === 'none') $urgency = 'medium';
|
||||
$reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg';
|
||||
$score += 40;
|
||||
@@ -5420,7 +5758,29 @@ function smartShopping(PDO $db): void {
|
||||
|
||||
if ($urgency === 'none') continue;
|
||||
|
||||
// Boost score for very frequent items
|
||||
// Family stock coverage: suppress items covered by other products in the same generic family.
|
||||
// For non-expired items: suppress if family has other stock (already bought an equivalent).
|
||||
// For expired items: suppress if the family has FRESH stock >= the expired qty in other products
|
||||
// e.g. Minestrone tradizione (expired 1/5) but Minesteone 12 verdure + Buon Minestrone = 590g → suppress
|
||||
// Critical-without-family-cover always shows so user knows something needs replacing.
|
||||
$sNameFamily = strtolower(trim($p['shopping_name'] ?? ''));
|
||||
if ($sNameFamily !== '') {
|
||||
if (!$isExpired && $urgency !== 'critical') {
|
||||
$familyTotal = $stockByShoppingName[$sNameFamily] ?? 0;
|
||||
$otherFamilyQty = $familyTotal - $qty;
|
||||
if ($otherFamilyQty > 0) {
|
||||
continue;
|
||||
}
|
||||
} elseif ($isExpired) {
|
||||
// For expired: check if OTHER family members have fresh stock covering the expired amount
|
||||
$familyFreshTotal = $freshStockByShoppingName[$sNameFamily] ?? 0;
|
||||
// freshStockByShoppingName counts this product's fresh_qty too (which is 0 if all expired)
|
||||
// So if familyFreshTotal > 0 it means OTHER products in family have fresh stock
|
||||
if ($familyFreshTotal > 0) {
|
||||
continue; // family has fresh stock → expired product is covered
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($useCount >= 8) $score += 15;
|
||||
elseif ($useCount >= 5) $score += 10;
|
||||
|
||||
@@ -5432,7 +5792,9 @@ function smartShopping(PDO $db): void {
|
||||
|
||||
// "Just restocked" suppression: if bought in the last 3 days AND stock is above 50%
|
||||
// of reference qty, skip non-expiry urgency flags. The product doesn't need rebuying yet.
|
||||
if ($justRestocked && $pctLeft >= 50 && !$isExpired && !$isExpiringSoon) {
|
||||
// Note: isExpiringSoon is intentionally excluded — if you have ≥50% stock it was already
|
||||
// filtered above (pctLeft < 50 required for expiringSoon urgency).
|
||||
if ($justRestocked && $pctLeft >= 50 && !$isExpired) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+261
-113
@@ -2500,7 +2500,7 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
||||
url += `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
|
||||
});
|
||||
}
|
||||
const opts = { method };
|
||||
const opts = { method, cache: 'no-store' };
|
||||
if (body) {
|
||||
opts.headers = { 'Content-Type': 'application/json', ...extraHeaders };
|
||||
opts.body = JSON.stringify(body);
|
||||
@@ -2599,6 +2599,20 @@ function showPage(pageId, param = null) {
|
||||
if (pageId === 'dashboard') {
|
||||
_bannerRefreshTimer = setInterval(() => loadBannerAlerts(), 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Auto-refresh shopping list every 45s while on shopping page so all clients stay in sync
|
||||
if (_shoppingPollTimer) { clearInterval(_shoppingPollTimer); _shoppingPollTimer = null; }
|
||||
if (pageId === 'shopping') {
|
||||
_shoppingPollTimer = setInterval(() => {
|
||||
loadShoppingList._bgCall = true;
|
||||
loadShoppingList();
|
||||
loadSmartShopping().then(() => {
|
||||
_syncOnBringFlags();
|
||||
renderSmartShopping();
|
||||
updateShoppingTabCounts();
|
||||
});
|
||||
}, 45 * 1000);
|
||||
}
|
||||
|
||||
// Stop scanner when leaving scan page
|
||||
if (pageId !== 'scan' && pageId !== 'ai') {
|
||||
@@ -3479,6 +3493,7 @@ let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
|
||||
let _bannerIndex = 0;
|
||||
let _bannerEditPending = false; // true when editing from banner → dismiss after save
|
||||
let _bannerRefreshTimer = null; // periodic refresh while on dashboard
|
||||
let _shoppingPollTimer = null; // periodic refresh while on shopping page (multi-client sync)
|
||||
|
||||
/**
|
||||
* Load suspicious quantities + consumption predictions + expired + expiring soon,
|
||||
@@ -3726,7 +3741,9 @@ function renderBannerItem() {
|
||||
: t('expiry.expired_suffix');
|
||||
titleEl.textContent = `${item.name}${item.brand ? ' (' + item.brand + ')' : ''} ${expiredSuffix}`;
|
||||
const baseDetail = t('dashboard.banner_expired_detail').replace('{when}', daysText).replace('{qty}', qtyDisplay);
|
||||
detailEl.innerHTML = `${baseDetail} <span class="banner-safety-tip banner-safety-${safety.level}">${safety.icon} ${safety.tip}</span>`;
|
||||
const locationTag = item.location ? ` · <strong>${escapeHtml(item.location)}</strong>` : '';
|
||||
const expiryTag = item.expiry_date ? ` · scade il <strong>${escapeHtml(item.expiry_date)}</strong>` : '';
|
||||
detailEl.innerHTML = `${baseDetail}${locationTag}${expiryTag} <span class="banner-safety-tip banner-safety-${safety.level}">${safety.icon} ${safety.tip}</span>`;
|
||||
let btns = '';
|
||||
if (safety.level !== 'danger') {
|
||||
btns += `<button class="btn-banner btn-banner-use" onclick="bannerQuickUse()">${t('dashboard.banner_expired_action_use')}</button>`;
|
||||
@@ -4852,6 +4869,19 @@ async function initScanner() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== EAN-13 / EAN-8 CHECKSUM VALIDATOR =====
|
||||
function validateEANChecksum(code) {
|
||||
const s = String(code).replace(/\D/g, '');
|
||||
if (s.length !== 13 && s.length !== 8) return false;
|
||||
const digits = s.split('').map(Number);
|
||||
const last = digits.pop();
|
||||
const sum = digits.reduce((acc, d, i) => {
|
||||
return acc + d * (s.length === 13 ? (i % 2 === 0 ? 1 : 3) : (i % 2 === 0 ? 3 : 1));
|
||||
}, 0);
|
||||
const check = (10 - (sum % 10)) % 10;
|
||||
return check === last;
|
||||
}
|
||||
|
||||
// ===== NATIVE BarcodeDetector SCANNER =====
|
||||
async function startNativeScanner(videoEl) {
|
||||
if (quaggaRunning) return;
|
||||
@@ -4868,6 +4898,8 @@ async function startNativeScanner(videoEl) {
|
||||
let lastDetected = '';
|
||||
let detectCount = 0;
|
||||
let detectionHistory = {};
|
||||
let quaggaParallelStarted = false;
|
||||
const startTime = Date.now();
|
||||
|
||||
scanLog('Native BarcodeDetector started');
|
||||
|
||||
@@ -4882,6 +4914,15 @@ async function startNativeScanner(videoEl) {
|
||||
frameCount++;
|
||||
|
||||
if (frameCount === 1) updateFeedback('scanning');
|
||||
|
||||
// After 2s without detection, also start Quagga in parallel as backup
|
||||
if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) {
|
||||
quaggaParallelStarted = true;
|
||||
scanLog('Native: 2s elapsed, spawning Quagga in parallel');
|
||||
quaggaRunning = false; // temporarily release so Quagga can start
|
||||
startQuaggaScanner(videoEl);
|
||||
quaggaRunning = true; // re-take ownership (Quagga will share)
|
||||
}
|
||||
|
||||
try {
|
||||
const barcodes = await detector.detect(videoEl);
|
||||
@@ -4903,11 +4944,14 @@ async function startNativeScanner(videoEl) {
|
||||
detectCount = 1;
|
||||
}
|
||||
|
||||
if (detectCount >= 2 || detectionHistory[code].count >= 2) {
|
||||
// EAN/UPC have built-in checksum — confirm on first hit for speed.
|
||||
// For other formats (code_128, code_39) require 2 to avoid false reads.
|
||||
const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format);
|
||||
if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) {
|
||||
scanning = false;
|
||||
quaggaRunning = false;
|
||||
updateFeedback(null);
|
||||
scanLog(`CONFIRMED: ${code} after ${frameCount} frames`);
|
||||
scanLog(`CONFIRMED: ${code} after ${frameCount} frames (${format})`);
|
||||
onBarcodeDetected(code);
|
||||
return;
|
||||
}
|
||||
@@ -4949,7 +4993,7 @@ function startQuaggaScanner(videoEl) {
|
||||
let detectionHistory = {};
|
||||
|
||||
// Alternate between full frame and center-cropped for better detection
|
||||
let scanPass = 0; // 0=full, 1=center-crop, 2=full-enhanced, 3=center-enhanced
|
||||
let scanPass = 0; // 0=full, 1=center-crop
|
||||
|
||||
function updateScannerFeedback(state) {
|
||||
if (!scannerLine) return;
|
||||
@@ -4962,12 +5006,13 @@ function startQuaggaScanner(videoEl) {
|
||||
const vh = videoEl.videoHeight;
|
||||
|
||||
if (pass % 2 === 0) {
|
||||
// Full frame
|
||||
canvas.width = vw;
|
||||
canvas.height = vh;
|
||||
ctx.drawImage(videoEl, 0, 0);
|
||||
// Full frame (scaled down for speed)
|
||||
const scale = 0.75;
|
||||
canvas.width = Math.round(vw * scale);
|
||||
canvas.height = Math.round(vh * scale);
|
||||
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
|
||||
} else {
|
||||
// Center crop: 60% of frame, focused on barcode area
|
||||
// Center crop: 70% wide, 40% tall — focused on barcode area
|
||||
const cropW = Math.round(vw * 0.7);
|
||||
const cropH = Math.round(vh * 0.4);
|
||||
const sx = Math.round((vw - cropW) / 2);
|
||||
@@ -4977,18 +5022,18 @@ function startQuaggaScanner(videoEl) {
|
||||
ctx.drawImage(videoEl, sx, sy, cropW, cropH, 0, 0, cropW, cropH);
|
||||
}
|
||||
|
||||
// Apply enhancement on passes 2,3 or always for front cam
|
||||
if (frontCam || pass >= 2) {
|
||||
// Apply enhancement for front cam or low-light
|
||||
if (frontCam) {
|
||||
enhanceCanvasForBarcode(ctx, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/jpeg', 0.95);
|
||||
return canvas.toDataURL('image/jpeg', 0.85);
|
||||
}
|
||||
|
||||
function scanFrame() {
|
||||
if (!scanning || !scannerStream) return;
|
||||
frameCount++;
|
||||
scanPass = (scanPass + 1) % 4;
|
||||
scanPass = (scanPass + 1) % 2;
|
||||
|
||||
const dataUrl = getFrameDataUrl(scanPass);
|
||||
|
||||
@@ -5001,29 +5046,29 @@ function startQuaggaScanner(videoEl) {
|
||||
const safetyTimer = setTimeout(() => {
|
||||
if (!callbackCalled && scanning) {
|
||||
scanLog(`Quagga timeout on f${frameCount}, retrying...`);
|
||||
setTimeout(scanFrame, 100);
|
||||
setTimeout(scanFrame, 50);
|
||||
}
|
||||
}, 5000);
|
||||
}, 2000);
|
||||
|
||||
try {
|
||||
const imgSize = Math.max(canvas.width, canvas.height);
|
||||
Quagga.decodeSingle({
|
||||
src: dataUrl,
|
||||
numOfWorkers: 0,
|
||||
inputStream: { size: Math.min(imgSize, 800) },
|
||||
inputStream: { size: Math.min(imgSize, 640) },
|
||||
decoder: {
|
||||
readers: [
|
||||
'ean_reader',
|
||||
'ean_8_reader',
|
||||
'code_128_reader',
|
||||
'code_39_reader',
|
||||
'upc_reader',
|
||||
'upc_e_reader'
|
||||
'upc_e_reader',
|
||||
'code_128_reader',
|
||||
'code_39_reader'
|
||||
],
|
||||
multiple: false
|
||||
},
|
||||
locate: true,
|
||||
locator: { patchSize: 'large', halfSample: false }
|
||||
locator: { patchSize: 'medium', halfSample: true }
|
||||
}, function(result) {
|
||||
callbackCalled = true;
|
||||
clearTimeout(safetyTimer);
|
||||
@@ -5047,11 +5092,14 @@ function startQuaggaScanner(videoEl) {
|
||||
}
|
||||
|
||||
const dominated = detectionHistory[code];
|
||||
if (detectCount >= 2 || dominated.count >= 2) {
|
||||
const passName2 = ['full','crop'][scanPass];
|
||||
// EAN/UPC: confirm on first hit (checksum validated)
|
||||
const highConf = ['ean_reader','ean_8_reader','upc_reader','upc_e_reader'].includes(format);
|
||||
if (highConf || detectCount >= 2 || dominated.count >= 2) {
|
||||
scanning = false;
|
||||
quaggaRunning = false;
|
||||
updateScannerFeedback(null);
|
||||
scanLog(`CONFIRMED: ${code} after ${frameCount} frames (consec:${detectCount}, total:${dominated.count})`);
|
||||
scanLog(`CONFIRMED: ${code} [${passName2}] f${frameCount} consec:${detectCount} total:${dominated.count}`);
|
||||
onBarcodeDetected(code);
|
||||
return;
|
||||
}
|
||||
@@ -5060,9 +5108,9 @@ function startQuaggaScanner(videoEl) {
|
||||
}
|
||||
if (scanning) {
|
||||
if (frameCount % 20 === 0) {
|
||||
scanLog(`Scanning... f${frameCount}, partials: ${partialCount}, pass: ${scanPass}`);
|
||||
scanLog(`Scanning... f${frameCount}, partials: ${partialCount}`);
|
||||
}
|
||||
setTimeout(scanFrame, 150);
|
||||
setTimeout(scanFrame, 60);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -5073,7 +5121,7 @@ function startQuaggaScanner(videoEl) {
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(scanFrame, 500);
|
||||
setTimeout(scanFrame, 200);
|
||||
}
|
||||
|
||||
// Enhance low-quality camera frames for better barcode recognition
|
||||
@@ -5244,19 +5292,31 @@ async function onBarcodeDetected(barcode) {
|
||||
|
||||
function submitManualBarcode() {
|
||||
const input = document.getElementById('manual-barcode-input');
|
||||
const barcode = (input.value || '').trim();
|
||||
if (!barcode) {
|
||||
showToast(t('error.barcode_empty'), 'error');
|
||||
input.focus();
|
||||
autoSubmitEAN(input, true);
|
||||
}
|
||||
|
||||
// Auto-submit when user finishes typing a valid EAN-13 or EAN-8
|
||||
function autoSubmitEAN(inputEl, force = false) {
|
||||
const raw = (inputEl.value || '').replace(/\D/g, '');
|
||||
inputEl.value = raw; // strip non-digits live
|
||||
if (!raw) return;
|
||||
const isComplete = raw.length === 13 || raw.length === 8;
|
||||
const isValid = isComplete && validateEANChecksum(raw);
|
||||
if (isValid) {
|
||||
// Auto-submit on valid EAN
|
||||
stopScanner();
|
||||
onBarcodeDetected(raw);
|
||||
return;
|
||||
}
|
||||
if (!/^\d{4,14}$/.test(barcode)) {
|
||||
showToast(t('error.barcode_format'), 'error');
|
||||
input.focus();
|
||||
return;
|
||||
if (force) {
|
||||
if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; }
|
||||
if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; }
|
||||
if (isComplete && !isValid) {
|
||||
showToast('⚠️ Checksum EAN errato — verifica le cifre', 'warning');
|
||||
}
|
||||
stopScanner();
|
||||
onBarcodeDetected(raw);
|
||||
}
|
||||
stopScanner();
|
||||
onBarcodeDetected(barcode);
|
||||
}
|
||||
|
||||
// ===== QUICK NAME ENTRY (for loose/unpackaged products) =====
|
||||
@@ -5628,10 +5688,19 @@ async function scanBarcodeForForm() {
|
||||
</div>
|
||||
<p style="text-align:center;margin-top:12px;color:var(--text-muted);font-size:0.88rem">${t('scanner.barcode_hint')}</p>
|
||||
<div style="margin-top:10px;text-align:center">
|
||||
<input type="text" id="pf-bc-manual" class="form-input" placeholder="${t('scanner.barcode_manual_placeholder')}" inputmode="numeric" style="max-width:260px;display:inline-block">
|
||||
<input type="text" id="pf-bc-manual" class="form-input" placeholder="${t('scanner.barcode_manual_placeholder')}" inputmode="numeric" maxlength="14" style="max-width:260px;display:inline-block" oninput="
|
||||
const raw=(this.value||'').replace(/\\D/g,''); this.value=raw;
|
||||
if((raw.length===13||raw.length===8)&&validateEANChecksum(raw)){
|
||||
stopStream();
|
||||
document.getElementById('pf-barcode').value=raw;
|
||||
_updateBarcodeHint();
|
||||
document.getElementById('modal-overlay').style.display='none';
|
||||
if(navigator.vibrate)navigator.vibrate(80);
|
||||
}
|
||||
">
|
||||
<button class="btn btn-primary" style="margin-top:8px;width:100%" onclick="
|
||||
const v = document.getElementById('pf-bc-manual').value.trim();
|
||||
if(v){ document.getElementById('pf-barcode').value=v; _updateBarcodeHint(); document.getElementById('modal-overlay').style.display='none'; }
|
||||
const v = (document.getElementById('pf-bc-manual').value||'').replace(/\\D/g,'');
|
||||
if(v){ stopStream(); document.getElementById('pf-barcode').value=v; _updateBarcodeHint(); document.getElementById('modal-overlay').style.display='none'; }
|
||||
">${t('scanner.barcode_use_btn')}</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -5660,8 +5729,11 @@ async function scanBarcodeForForm() {
|
||||
const barcodes = await detector.detect(video);
|
||||
if (barcodes.length > 0) {
|
||||
const code = barcodes[0].rawValue;
|
||||
const fmt = barcodes[0].format;
|
||||
detectionHistory[code] = (detectionHistory[code] || 0) + 1;
|
||||
if (detectionHistory[code] >= 2) {
|
||||
// EAN/UPC: confirm immediately (checksum-validated by detector)
|
||||
const highConf = ['ean_13','ean_8','upc_a','upc_e'].includes(fmt);
|
||||
if (highConf || detectionHistory[code] >= 2) {
|
||||
scanning = false;
|
||||
stopStream();
|
||||
overlayEl.style.display = 'none';
|
||||
@@ -7498,6 +7570,67 @@ function _matchBringToSmart(bringName, smartItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a small auto-dismissing bottom bar asking the user if the opened product
|
||||
* was put under vacuum seal. Auto-confirms after DURATION ms with the default value
|
||||
* (if it was already vacuum sealed → default yes, otherwise → default no).
|
||||
* @param {number} openedId - inventory row ID of the opened item
|
||||
* @param {number|boolean} wasVacuumSealed - previous vacuum_sealed state (0/1)
|
||||
*/
|
||||
function _showVacuumPrompt(openedId, wasVacuumSealed) {
|
||||
const DURATION = 8000;
|
||||
const defaultYes = !!wasVacuumSealed;
|
||||
|
||||
const old = document.getElementById('_vacuum-prompt');
|
||||
if (old) old.remove();
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.id = '_vacuum-prompt';
|
||||
bar.style.cssText = [
|
||||
'position:fixed', 'bottom:80px', 'left:50%', 'transform:translateX(-50%)',
|
||||
'z-index:9999', 'background:#1e293b', 'color:#fff', 'border-radius:14px',
|
||||
'padding:12px 16px', 'display:flex', 'align-items:center', 'gap:10px',
|
||||
'box-shadow:0 4px 24px rgba(0,0,0,0.5)', 'max-width:360px',
|
||||
'width:calc(100% - 32px)', 'box-sizing:border-box', 'overflow:hidden'
|
||||
].join(';');
|
||||
bar.innerHTML = `
|
||||
<span style="flex:1;font-size:0.9rem;line-height:1.3">🔒 Messo <b>sotto vuoto</b>?</span>
|
||||
<button id="_vac-yes" style="background:#22c55e;color:#fff;border:none;border-radius:8px;padding:7px 14px;font-weight:700;cursor:pointer;white-space:nowrap">Sì</button>
|
||||
<button id="_vac-no" style="background:#475569;color:#fff;border:none;border-radius:8px;padding:7px 14px;font-weight:700;cursor:pointer;white-space:nowrap">No</button>
|
||||
<div id="_vac-bar" style="position:absolute;bottom:0;left:0;height:3px;background:#60a5fa;border-radius:0;width:100%"></div>
|
||||
`;
|
||||
document.body.appendChild(bar);
|
||||
|
||||
let dismissed = false;
|
||||
let rafH = null;
|
||||
let timerH = null;
|
||||
|
||||
function dismiss(vacuum) {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
if (timerH) clearTimeout(timerH);
|
||||
if (rafH) cancelAnimationFrame(rafH);
|
||||
bar.remove();
|
||||
api('inventory_update', {}, 'POST', { id: openedId, vacuum_sealed: vacuum ? 1 : 0 })
|
||||
.then(() => { if (vacuum) showToast('🔒 Sotto vuoto registrato', 'success'); })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
bar.querySelector('#_vac-yes').addEventListener('click', () => dismiss(true));
|
||||
bar.querySelector('#_vac-no').addEventListener('click', () => dismiss(false));
|
||||
|
||||
const barEl = bar.querySelector('#_vac-bar');
|
||||
const start = performance.now();
|
||||
function tick() {
|
||||
if (dismissed) return;
|
||||
const pct = Math.min(100, (performance.now() - start) / DURATION * 100);
|
||||
if (barEl) barEl.style.width = (100 - pct) + '%';
|
||||
if (pct < 100) rafH = requestAnimationFrame(tick);
|
||||
}
|
||||
rafH = requestAnimationFrame(tick);
|
||||
timerH = setTimeout(() => dismiss(defaultYes), DURATION);
|
||||
}
|
||||
|
||||
function showLowStockBringPrompt(result, afterCallback) {
|
||||
const name = result.product_name || currentProduct?.name || '';
|
||||
// Generic shopping name (e.g. "Affettato" for "Mortadella IGP"). Falls back to
|
||||
@@ -7924,8 +8057,17 @@ async function submitUse(e) {
|
||||
const moveCallback = result.remaining > 0
|
||||
? () => showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id)
|
||||
: () => showPage('dashboard');
|
||||
// Check low stock → Bring! prompt
|
||||
showLowStockBringPrompt(result, moveCallback);
|
||||
// Check low stock → Bring! prompt, then vacuum seal prompt if product was opened
|
||||
const afterLowStock = moveCallback;
|
||||
showLowStockBringPrompt(result, afterLowStock);
|
||||
// Show vacuum sealed prompt when some stock remains and it's a container type
|
||||
// (conf/weighted units) or the item was previously vacuum sealed.
|
||||
// Skip for pz "counting" items (e.g. 3 mele → 2 mele: no vacuum concept).
|
||||
const _vacUnit = result.product_unit || currentProduct?.unit || '';
|
||||
const _vacContainer = ['conf','g','kg','ml','l'].includes(_vacUnit) || !!(result.opened_vacuum_sealed);
|
||||
if (result.opened_id && result.remaining > 0 && _vacContainer) {
|
||||
setTimeout(() => _showVacuumPrompt(result.opened_id, result.opened_vacuum_sealed ?? 0), 600);
|
||||
}
|
||||
} else if (result.duplicate) {
|
||||
// Silently ignore: this was a scale double-trigger, not a real error
|
||||
} else {
|
||||
@@ -8995,90 +9137,100 @@ async function fetchAllPrices(forceRefresh = false) {
|
||||
* Items not matching any DB product are left untouched (likely manually added by user).
|
||||
*/
|
||||
async function cleanupObsoleteBringItems() {
|
||||
// Run at most once every 30 minutes
|
||||
// Rate-limit: run at most once every 3 minutes
|
||||
const lastCleanup = parseInt(localStorage.getItem('_bringCleanupTs') || '0');
|
||||
if (Date.now() - lastCleanup < 30 * 60 * 1000) return;
|
||||
if (Date.now() - lastCleanup < 3 * 60 * 1000) return;
|
||||
localStorage.setItem('_bringCleanupTs', String(Date.now()));
|
||||
if (!shoppingItems.length || !smartShoppingItems.length) return;
|
||||
|
||||
// Load live inventory (has actual quantities unlike products_list)
|
||||
// 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;
|
||||
};
|
||||
|
||||
// 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.
|
||||
const smartShoppingNames = new Set(
|
||||
smartShoppingItems.flatMap(si => [
|
||||
si.name?.toLowerCase(),
|
||||
si.shopping_name?.toLowerCase()
|
||||
].filter(Boolean))
|
||||
);
|
||||
const smartShoppingFirstToks = new Map();
|
||||
for (const si of smartShoppingItems) {
|
||||
for (const tok of _nameTokens(si.name || '')) {
|
||||
if (!smartShoppingFirstToks.has(tok)) smartShoppingFirstToks.set(tok, si);
|
||||
}
|
||||
if (si.shopping_name) {
|
||||
for (const tok of _nameTokens(si.shopping_name)) {
|
||||
if (!smartShoppingFirstToks.has(tok)) smartShoppingFirstToks.set(tok, si);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load live inventory from server for stock check
|
||||
let invItems = [];
|
||||
try {
|
||||
const res = await api('inventory_list');
|
||||
invItems = res.inventory || [];
|
||||
} catch (e) { return; }
|
||||
|
||||
// Build: every significant token of in-stock products → total qty
|
||||
// Any-token matching groups product families:
|
||||
// 'Passata di pomodoro' + 'Polpa di pomodoro' share 'pomodoro' → same need
|
||||
const stockByAnyToken = new Map();
|
||||
// stock by any token (name) + by shopping_name
|
||||
const stockByTok = new Map();
|
||||
const stockBySName = new Map();
|
||||
for (const inv of invItems) {
|
||||
const qty = parseFloat(inv.quantity || 0);
|
||||
if (qty <= 0) continue;
|
||||
const expiry = inv.expiry_date;
|
||||
const expired = expiry && new Date(expiry) < new Date();
|
||||
if (qty <= 0 || expired) continue;
|
||||
for (const tok of _nameTokens(inv.name || '')) {
|
||||
stockByAnyToken.set(tok, (stockByAnyToken.get(tok) || 0) + qty);
|
||||
stockByTok.set(tok, (stockByTok.get(tok) || 0) + qty);
|
||||
}
|
||||
const sn = (inv.shopping_name || '').toLowerCase().trim();
|
||||
if (sn) stockBySName.set(sn, (stockBySName.get(sn) || 0) + qty);
|
||||
}
|
||||
|
||||
// Build: first token of smart item name → smart item
|
||||
const smartByFirstToken = new Map();
|
||||
for (const si of smartShoppingItems) {
|
||||
const first = _nameTokens(si.name)[0];
|
||||
if (first && !smartByFirstToken.has(first)) smartByFirstToken.set(first, si);
|
||||
// Also index shopping_name first token
|
||||
if (si.shopping_name) {
|
||||
const sFirst = _nameTokens(si.shopping_name)[0];
|
||||
if (sFirst && !smartByFirstToken.has(sFirst)) smartByFirstToken.set(sFirst, si);
|
||||
}
|
||||
}
|
||||
|
||||
// User-pinned: items manually added via any path — never auto-remove
|
||||
let userPinned;
|
||||
try {
|
||||
const raw = localStorage.getItem('_userPinnedBring');
|
||||
const map = raw ? JSON.parse(raw) : {};
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const k of Object.keys(map)) {
|
||||
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
|
||||
}
|
||||
if (changed) localStorage.setItem('_userPinnedBring', JSON.stringify(map));
|
||||
userPinned = map;
|
||||
} catch(e) { userPinned = {}; }
|
||||
|
||||
// Auto-added set: only items the app itself auto-added are candidates for cleanup
|
||||
const autoAdded = _getAutoAddedBring();
|
||||
|
||||
const toRemove = [];
|
||||
for (const item of shoppingItems) {
|
||||
const nameLower = item.name.toLowerCase();
|
||||
const itemFirst = _nameTokens(item.name)[0];
|
||||
const itemToks = _nameTokens(item.name);
|
||||
const itemFirst = itemToks[0];
|
||||
|
||||
// Safety: only clean up items the app auto-added — NEVER remove manually-added ones
|
||||
const isAutoAdded = !!(autoAdded[nameLower] ||
|
||||
(itemFirst && Object.keys(autoAdded).some(k => _nameTokens(k)[0] === itemFirst)));
|
||||
if (!isAutoAdded) continue;
|
||||
// Only remove items the app put there
|
||||
if (!isAppAdded(item)) continue;
|
||||
|
||||
// User explicitly pinned this item → skip
|
||||
if (userPinned[nameLower]) continue;
|
||||
// Find matching smart item
|
||||
const smartSi = itemFirst ? smartShoppingFirstToks.get(itemFirst) : undefined;
|
||||
|
||||
// Find smart item by first-token match (strict — avoids "latte" matching "latte di soia")
|
||||
const smartSi = itemFirst ? smartByFirstToken.get(itemFirst) : undefined;
|
||||
|
||||
// Smart still considers this critical or high urgency → keep it on the list
|
||||
// Smart still flags this as critical or high → keep it
|
||||
if (smartSi && (smartSi.urgency === 'critical' || smartSi.urgency === 'high')) continue;
|
||||
|
||||
// Out of stock → the user still needs to buy it, keep it
|
||||
// Smart says medium AND low stock → keep it
|
||||
if (smartSi && smartSi.urgency === 'medium' && (smartSi.pct_left ?? 100) < 60) continue;
|
||||
// Smart has it with 0 qty → keep it (user genuinely needs it)
|
||||
if (smartSi && (smartSi.current_qty ?? 0) <= 0) continue;
|
||||
|
||||
// Smart predicts medium urgency AND stock < 60% → keep it
|
||||
if (smartSi && smartSi.urgency === 'medium' && (smartSi.pct_left ?? 100) < 60) continue;
|
||||
// If the item IS still in smart_shopping (but not urgent) AND has no local stock at all,
|
||||
// give benefit of the doubt and keep it.
|
||||
// If the item is NOT in smart_shopping at all → trust the server: it's covered → remove.
|
||||
if (smartSi) {
|
||||
// Still in smart_shopping (low urgency): verify some stock exists before removing
|
||||
const hasStock = itemToks.some(tok => (stockByTok.get(tok) || 0) > 0)
|
||||
|| (stockBySName.get(nameLower) || 0) > 0;
|
||||
if (!hasStock) continue;
|
||||
}
|
||||
// else: not in smart_shopping at all → server decided it's covered → safe to remove
|
||||
|
||||
// Check actual inventory stock for this exact item (first-token match)
|
||||
const stockQty = itemFirst ? (stockByAnyToken.get(itemFirst) || 0) : 0;
|
||||
if (stockQty <= 0) continue; // no related stock → don't remove
|
||||
|
||||
// All guards passed: item is auto-added, stock is sufficient, not urgently needed
|
||||
// All guards passed: app-added and not urgently needed → remove from Bring!
|
||||
toRemove.push(item);
|
||||
}
|
||||
|
||||
@@ -9559,19 +9711,15 @@ async function loadShoppingCount() {
|
||||
el.classList.remove('stat-loading');
|
||||
}
|
||||
}
|
||||
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch
|
||||
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
|
||||
_updateSmartUrgencyBadge();
|
||||
} else {
|
||||
try {
|
||||
const smart = await api('smart_shopping');
|
||||
if (smart.success && smart.items) {
|
||||
smartShoppingItems = smart.items;
|
||||
_smartShoppingLastFetch = Date.now();
|
||||
_updateSmartUrgencyBadge();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// Smart urgency badge: always fetch fresh data from server (no browser-side gate)
|
||||
try {
|
||||
const smart = await api('smart_shopping');
|
||||
if (smart.success && smart.items) {
|
||||
smartShoppingItems = smart.items;
|
||||
_smartShoppingLastFetch = Date.now();
|
||||
_updateSmartUrgencyBadge();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
_updateDashboardPriceTotal();
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260508b">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260508c">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||
@@ -67,7 +67,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.4</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.5</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -227,7 +227,7 @@
|
||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||
<div class="barcode-manual-entry">
|
||||
<div class="barcode-input-row">
|
||||
<input type="text" id="manual-barcode-input" class="form-input" placeholder="Inserisci codice a barre..." inputmode="numeric" pattern="[0-9]*" onkeydown="if(event.key==='Enter')submitManualBarcode()" data-i18n-placeholder="scan.barcode_placeholder">
|
||||
<input type="text" id="manual-barcode-input" class="form-input" placeholder="Inserisci codice a barre..." inputmode="numeric" pattern="[0-9]*" maxlength="14" oninput="autoSubmitEAN(this)" onkeydown="if(event.key==='Enter')submitManualBarcode()" data-i18n-placeholder="scan.barcode_placeholder">
|
||||
<button class="btn btn-primary" onclick="submitManualBarcode()" data-i18n="btn.search">🔍 Cerca</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1462,6 +1462,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260508c"></script>
|
||||
<script src="assets/js/app.js?v=20260510d"></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.4",
|
||||
"version": "1.7.5",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
Reference in New Issue
Block a user