From a38a5d670f84d43fa9680d15a9ab6725229e9f05 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Sun, 29 Mar 2026 14:12:37 +0000 Subject: [PATCH] feat: lista spesa con tab Da comprare/In previsione, tag, frequenza, tap-to-scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Counter nei tab aggiornati dinamicamente - Auto-aggiunta prodotti CRITICI a Bring! al caricamento (1x per sessione) - Badge urgenza e frequenza sugli item in lista (cross-ref smart shopping) - Tag locali per item (Urgente/Priorità/Verificare) con menu dropdown - Ordinamento automatico per frequenza utilizzo (item più usati in cima) - Tap su un item → scanner barcode, con banner 'Trovato! Rimuovi dalla lista' - Fix pctLeft: usa max(1, qty) come fallback refQty per evitare falsi alert - Fix daysLeft capped a 365gg per pulire stringhe di previsione - Back button on action page → torna a shopping se aperto da lista --- api/database.php | 237 +++++++++++++++++++ api/index.php | 264 +++++++++++++++++++++- assets/css/style.css | 479 +++++++++++++++++++++++++++++++++++++++ assets/js/app.js | 525 +++++++++++++++++++++++++++++++++++++++++-- data/dispensa.db | Bin 253952 -> 253952 bytes index.html | 117 +++++++--- 6 files changed, 1571 insertions(+), 51 deletions(-) diff --git a/api/database.php b/api/database.php index 0c250be..fbc2845 100644 --- a/api/database.php +++ b/api/database.php @@ -147,4 +147,241 @@ function migrateDB(PDO $db): void { if (!in_array('vacuum_sealed', $invColNames)) { $db->exec("ALTER TABLE inventory ADD COLUMN vacuum_sealed INTEGER DEFAULT 0"); } + + // Add opened_at column to inventory if missing + if (!in_array('opened_at', $invColNames)) { + $db->exec("ALTER TABLE inventory ADD COLUMN opened_at DATETIME DEFAULT NULL"); + // Backfill: detect already-opened items and set opened_at + recalculate expiry + backfillOpenedItems($db); + } + + // Migration v2: recalculate sealed fridge item expiry (fridge extends shelf life) + $migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn(); + if (!$migrated) { + recalcSealedFridgeExpiry($db); + $db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')"); + } +} + +/** + * Backfill opened_at for existing inventory items that appear to be opened. + * An item is considered opened if: + * - conf unit with fractional quantity + * - weight/volume unit (g,kg,ml,l) with quantity < default_quantity + * Uses updated_at as the approximate opened_at date. + * Recalculates expiry_date based on opened shelf life from opened_at. + */ +function backfillOpenedItems(PDO $db): void { + $stmt = $db->query(" + SELECT i.id, i.quantity, i.location, i.updated_at, i.expiry_date, i.vacuum_sealed, + p.name, p.category, p.unit, p.default_quantity + FROM inventory i + JOIN products p ON i.product_id = p.id + WHERE i.quantity > 0 + "); + $rows = $stmt->fetchAll(); + + foreach ($rows as $row) { + $isOpened = false; + $unit = $row['unit'] ?: 'pz'; + $qty = (float)$row['quantity']; + $defQty = (float)($row['default_quantity'] ?: 0); + + if ($unit === 'conf') { + $frac = $qty - floor($qty + 0.001); + if ($frac > 0.001) $isOpened = true; + } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $qty < $defQty - 0.001) { + $isOpened = true; + } + + if (!$isOpened) continue; + + $openedAt = $row['updated_at']; + $openedDays = estimateOpenedExpiryDaysPHP($row['name'], $row['category'], $row['location']); + if ($row['vacuum_sealed']) $openedDays = (int)round($openedDays * 1.5); + + // Calculate new expiry from opened_at + $newExpiry = date('Y-m-d', strtotime($openedAt . " +{$openedDays} days")); + + $upd = $db->prepare("UPDATE inventory SET opened_at = ?, expiry_date = ? WHERE id = ?"); + $upd->execute([$openedAt, $newExpiry, $row['id']]); + } +} + +/** + * Estimate shelf life in days for an opened product. + * Much shorter than sealed shelf life. + */ +function estimateOpenedExpiryDaysPHP(string $name, string $category, string $location): int { + $n = mb_strtolower($name); + $cat = mb_strtolower($category); + $loc = mb_strtolower($location); + + // Freezer: opened items still last a long time + if ($loc === 'freezer') return 90; + // Dispensa: opened dry goods + if ($loc === 'dispensa') return 30; + + // Specific product overrides (fridge) + if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) return 3; + if (preg_match('/latte\s+uht|latte\s+a\s+lunga/', $n)) return 5; + if (preg_match('/latte/', $n)) return 4; + if (preg_match('/yogurt/', $n)) return 3; + if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 2; + if (preg_match('/philadelphia|spalmabile/', $n)) return 7; + if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5; + if (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) return 21; + if (preg_match('/formaggio/', $n)) return 10; + if (preg_match('/burro/', $n)) return 21; + if (preg_match('/panna/', $n)) return 3; + if (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) return 3; + if (preg_match('/prosciutto\s+crudo|salame|bresaola|speck|pancetta|nduja/', $n)) return 7; + if (preg_match('/pollo|tacchino|maiale|manzo|vitello/', $n)) return 2; + if (preg_match('/salmone|tonno\s+fresco|pesce/', $n)) return 2; + if (preg_match('/passata|pelati|polpa|sugo/', $n)) return 5; + if (preg_match('/marmellata|confettura/', $n)) return 30; + if (preg_match('/miele/', $n)) return 180; + if (preg_match('/nutella/', $n)) return 60; + if (preg_match('/succo|spremuta/', $n)) return 4; + if (preg_match('/olio|aceto/', $n)) return 90; + if (preg_match('/vino|birra/', $n)) return 5; + if (preg_match('/limone|limmi/', $n)) return 21; + if (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) return 3; + if (preg_match('/insalata|rucola|spinaci/', $n)) return 3; + + // Category fallbacks + if (preg_match('/dairy|latticin|lait|dairies/', $cat)) return 5; + if (preg_match('/meat|carne|meats/', $cat)) return 3; + if (preg_match('/fish|pesce/', $cat)) return 2; + if (preg_match('/fruit|frutta/', $cat)) return 5; + if (preg_match('/verdur|vegetable|plant-based/', $cat)) return 5; + if (preg_match('/conserve/', $cat)) return 5; + if (preg_match('/condimenti|sauce/', $cat)) return 21; + if (preg_match('/bevand|beverage/', $cat)) return 4; + + return 5; // safe default for fridge +} + +/** + * Estimate sealed shelf life in days, with fridge/freezer extensions. + * Mirrors the JS estimateExpiryDays() function. + */ +function estimateSealedExpiryDaysPHP(string $name, string $category, string $location): int { + $n = mb_strtolower($name); + $cat = mb_strtolower($category); + $loc = mb_strtolower($location); + + $days = null; + + // Specific product overrides + if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) $days = 7; + elseif (preg_match('/latte\s+uht|latte\s+a\s+lunga/', $n)) $days = 90; + elseif (preg_match('/yogurt/', $n)) $days = 21; + elseif (preg_match('/mozzarella|burrata|stracciatella/', $n)) $days = 5; + elseif (preg_match('/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) $days = 10; + elseif (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) $days = 60; + elseif (preg_match('/burro/', $n)) $days = 60; + elseif (preg_match('/panna/', $n)) $days = 14; + elseif (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) $days = 7; + elseif (preg_match('/prosciutto\s+crudo|salame|bresaola|speck/', $n)) $days = 30; + elseif (preg_match('/nduja/', $n)) $days = 90; + elseif (preg_match('/uova/', $n)) $days = 28; + elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5; + elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14; + elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5; + elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3; + elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2; + elseif (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) $days = 1095; + elseif (preg_match('/surgelat|frozen|findus|4\s*salti/', $n)) $days = 180; + elseif (preg_match('/gelato/', $n)) $days = 365; + elseif (preg_match('/succo|spremuta/', $n)) $days = 7; + elseif (preg_match('/birra|vino/', $n)) $days = 365; + elseif (preg_match('/acqua/', $n)) $days = 365; + elseif (preg_match('/mela|mele\b/', $n)) $days = 7; + elseif (preg_match('/arancia|arance|mandarini|agrumi/', $n)) $days = 7; + elseif (preg_match('/banana|banane/', $n)) $days = 5; + elseif (preg_match('/pera|pere\b|fragola|fragole|uva|kiwi/', $n)) $days = 5; + elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7; + elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5; + elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10; + elseif (preg_match('/patata|patate/', $n)) $days = 14; + elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180; + elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365; + elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730; + elseif (preg_match('/olio|aceto/', $n)) $days = 548; + + if ($days === null) { + // Category fallbacks + $catMap = [ + 'latticini' => 7, 'carne' => 4, 'pesce' => 3, 'frutta' => 7, 'verdura' => 7, + 'pasta' => 730, 'pane' => 4, 'surgelati' => 180, 'bevande' => 365, 'condimenti' => 365, + 'snack' => 180, 'conserve' => 730, 'cereali' => 365, 'igiene' => 1095, 'pulizia' => 1095, + ]; + $days = 180; + foreach ($catMap as $key => $d) { + if (strpos($cat, $key) !== false) { $days = $d; break; } + } + } + + // Fridge extends shelf life for produce and short-lived items + if ($loc === 'frigo') { + if (preg_match('/mela|mele/', $n)) $days = max($days, 28); + elseif (preg_match('/arancia|arance|agrumi|mandarini|limone|limoni/', $n)) $days = max($days, 21); + elseif (preg_match('/carota|carote/', $n)) $days = max($days, 21); + elseif (preg_match('/cipolla/', $n)) $days = max($days, 14); + elseif (preg_match('/patata|patate/', $n)) $days = max($days, 21); + elseif (preg_match('/pera|pere/', $n)) $days = max($days, 21); + elseif (preg_match('/kiwi/', $n)) $days = max($days, 28); + elseif (preg_match('/uva/', $n)) $days = max($days, 14); + elseif (preg_match('/fragola|fragole/', $n)) $days = max($days, 7); + elseif (preg_match('/peperoni/', $n)) $days = max($days, 14); + elseif (preg_match('/zucchina|zucchine/', $n)) $days = max($days, 14); + elseif (preg_match('/melanzane/', $n)) $days = max($days, 14); + elseif (preg_match('/broccoli|cavolfiore|cavolo/', $n)) $days = max($days, 10); + elseif ($days <= 7 && preg_match('/frutta|fruit|verdur|vegetable|plant-based/', $cat)) { + $days = (int)round($days * 2); + } + } + + // Freezer extends shelf life significantly + if ($loc === 'freezer' && $days < 180) { + if ($days <= 4) $days = 120; + elseif ($days <= 14) $days = 75; + elseif ($days <= 30) $days = 120; + else $days = max($days, 180); + } + + return $days; +} + +/** + * Recalculate expiry for sealed (non-opened) fridge items with new fridge-aware logic. + */ +function recalcSealedFridgeExpiry(PDO $db): void { + $stmt = $db->query(" + SELECT i.id, i.added_at, i.vacuum_sealed, i.opened_at, i.expiry_date, + p.name, p.category + FROM inventory i + JOIN products p ON i.product_id = p.id + WHERE i.location = 'frigo' AND i.opened_at IS NULL AND i.quantity > 0 + "); + $rows = $stmt->fetchAll(); + + foreach ($rows as $row) { + $days = estimateSealedExpiryDaysPHP($row['name'], $row['category'], 'frigo'); + if ($row['vacuum_sealed']) $days = getVacuumExpiryDaysPHP($days); + $newExpiry = date('Y-m-d', strtotime($row['added_at'] . " +{$days} days")); + // Only extend expiry, never shorten it + if ($row['expiry_date'] && $newExpiry <= $row['expiry_date']) continue; + $upd = $db->prepare("UPDATE inventory SET expiry_date = ? WHERE id = ?"); + $upd->execute([$newExpiry, $row['id']]); + } +} + +function getVacuumExpiryDaysPHP(int $baseDays): int { + if ($baseDays <= 7) return (int)round($baseDays * 3); + if ($baseDays <= 14) return (int)round($baseDays * 3); + if ($baseDays <= 30) return (int)round($baseDays * 2.5); + if ($baseDays <= 90) return (int)round($baseDays * 2.5); + return (int)round($baseDays * 1.5); } diff --git a/api/index.php b/api/index.php index 33659dc..3049617 100644 --- a/api/index.php +++ b/api/index.php @@ -115,6 +115,9 @@ try { case 'bring_suggest': bringSuggestItems($db); break; + case 'smart_shopping': + smartShopping($db); + break; case 'save_settings': saveSettings(); @@ -468,7 +471,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 + COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at FROM inventory i JOIN products p ON i.product_id = p.id "; @@ -624,7 +627,7 @@ function useFromInventory(PDO $db): void { return; } - $stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY (quantity != CAST(CAST(quantity AS INTEGER) AS REAL)) DESC, quantity ASC"); + $stmt = $db->prepare("SELECT id, quantity, opened_at, vacuum_sealed FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY (quantity != CAST(CAST(quantity AS INTEGER) AS REAL)) DESC, quantity ASC"); $stmt->execute([$productId, $location]); $existing = $stmt->fetch(); @@ -640,7 +643,7 @@ function useFromInventory(PDO $db): void { // Auto-split conf products: separate whole confs from opened (fractional) part $openedId = null; - $stmt2 = $db->prepare("SELECT unit, default_quantity, package_unit FROM products WHERE id = ?"); + $stmt2 = $db->prepare("SELECT name, category, unit, default_quantity, package_unit FROM products WHERE id = ?"); $stmt2->execute([$productId]); $prodInfo = $stmt2->fetch(); @@ -662,8 +665,13 @@ function useFromInventory(PDO $db): void { $newFraction = round($fraction - $quantity, 6); if ($newFraction > 0.001) { - $stmt3 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)"); - $stmt3->execute([$productId, $location, $newFraction, $origRow['expiry_date'], $origRow['vacuum_sealed'] ?? 0]); + // Opened item: calculate shorter shelf life from now + $vacuum = (int)($origRow['vacuum_sealed'] ?? 0); + $openedDays = estimateOpenedExpiryDaysPHP($prodInfo['name'] ?? '', $prodInfo['category'] ?? '', $location); + if ($vacuum) $openedDays = (int)round($openedDays * 1.5); + $openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days")); + $stmt3 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"); + $stmt3->execute([$productId, $location, $newFraction, $openedExpiry, $vacuum]); $openedId = (int)$db->lastInsertId(); } @@ -684,8 +692,33 @@ function useFromInventory(PDO $db): void { $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); $stmt->execute([$existing['id']]); } else { - $stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$newQty, $existing['id']]); + // Check if item is now opened (first use reduces quantity) + $wasOpened = !empty($existing['opened_at']); + $isNowOpened = false; + $unit = $prodInfo['unit'] ?? 'pz'; + $defQty = (float)($prodInfo['default_quantity'] ?? 0); + if ($unit === 'conf') { + $w = floor($newQty + 0.001); + $f = round($newQty - $w, 6); + if ($f > 0.001) $isNowOpened = true; + } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $newQty < $defQty - 0.001) { + $isNowOpened = true; + } + + if ($isNowOpened && !$wasOpened) { + // First time opened: recalculate expiry with shorter shelf life + $pName = $prodInfo['name'] ?? ''; + $pCat = $prodInfo['category'] ?? ''; + $vacuum = (int)($existing['vacuum_sealed'] ?? 0); + $openedDays = estimateOpenedExpiryDaysPHP($pName, $pCat, $location); + if ($vacuum) $openedDays = (int)round($openedDays * 1.5); + $openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days")); + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newQty, $openedExpiry, $existing['id']]); + } else { + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newQty, $existing['id']]); + } } // Log transaction @@ -2160,6 +2193,223 @@ function bringCleanSpecs(): void { echo json_encode(['success' => true, 'cleaned' => $cleaned]); } +/** + * Smart Shopping List: analyzes usage frequency, stock levels, expiry to produce + * intelligent urgency-ranked shopping recommendations. + */ +function smartShopping(PDO $db): void { + $now = time(); + $today = date('Y-m-d'); + + // 1. Get all products with their inventory and transaction history + $products = $db->query(" + SELECT p.id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit + FROM products p + ORDER BY p.name + ")->fetchAll(); + + // 2. Get all inventory grouped by product + $invStmt = $db->query(" + 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 + FROM inventory i + WHERE i.quantity > 0 + GROUP BY i.product_id + "); + $inventory = []; + foreach ($invStmt->fetchAll() as $inv) { + $inventory[$inv['product_id']] = $inv; + } + + // 3. Get transaction stats per product + $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 + FROM transactions + GROUP BY product_id + "); + $txData = []; + foreach ($txStmt->fetchAll() as $tx) { + $txData[$tx['product_id']] = $tx; + } + + // 4. Fetch current Bring! list to know what's already there + $bringItems = []; + try { + $auth = bringAuth(); + if ($auth) { + $listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}"); + if ($listData && isset($listData['purchase'])) { + foreach ($listData['purchase'] as $bi) { + $bringItems[mb_strtolower(bringToItalian($bi['name'] ?? ''))] = true; + $bringItems[mb_strtolower($bi['name'] ?? '')] = true; + } + } + } + } catch (Exception $e) { /* ignore */ } + + // 5. Analyze each product + $items = []; + foreach ($products as $p) { + $pid = $p['id']; + $inv = $inventory[$pid] ?? null; + $tx = $txData[$pid] ?? null; + + // Skip products never bought/used and not in inventory + if (!$tx && !$inv) continue; + + $qty = $inv ? (float)$inv['total_qty'] : 0; + $unit = $p['unit'] ?: 'pz'; + $defQty = (float)($p['default_quantity'] ?: 0); + $isOpened = $inv && !empty($inv['opened_at']); + + // --- Usage frequency --- + $useCount = $tx ? (int)$tx['use_count'] : 0; + $buyCount = $tx ? (int)$tx['buy_count'] : 0; + $totalUsed = $tx ? (float)$tx['total_used'] : 0; + $totalBought = $tx ? (float)$tx['total_bought'] : 0; + + // Days since first purchase + $firstIn = $tx && $tx['first_in'] ? strtotime($tx['first_in']) : null; + $lastIn = $tx && $tx['last_in'] ? strtotime($tx['last_in']) : null; + $lastOut = $tx && $tx['last_out'] ? strtotime($tx['last_out']) : null; + $daysSinceFirst = $firstIn ? max(1, ($now - $firstIn) / 86400) : 999; + + // Average daily consumption rate + $dailyRate = $daysSinceFirst < 999 && $totalUsed > 0 ? $totalUsed / $daysSinceFirst : 0; + + // Days of stock remaining + $daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0); + + // --- Expiry check --- + $expiryDate = $inv ? $inv['nearest_expiry'] : null; + $daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999; + $isExpired = $daysToExpiry < 0; + $isExpiringSoon = !$isExpired && $daysToExpiry <= 3; + + // --- 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 + $refQty = $totalBought > 0 && $buyCount > 0 + ? $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); + + // Cap daysLeft at a reasonable ceiling to avoid 999-day noise in reason strings + $daysLeft = min($daysLeft, 365); + + // --- Determine urgency --- + $urgency = 'none'; // none, low, medium, high, critical + $reasons = []; + $score = 0; + + // Out of stock + if ($qty <= 0) { + if ($useCount >= 3 || $buyCount >= 2) { + $urgency = 'critical'; + $reasons[] = 'Esaurito'; + $score += 100; + if ($useCount >= 5) { $score += 20; $reasons[] = "Uso frequente ({$useCount}x)"; } + } else { + // Rarely used — not urgent even if finished + continue; + } + } + + // Almost finished + if ($qty > 0 && $pctLeft <= 15) { + $urgency = 'high'; + $reasons[] = 'Quasi finito (' . round($pctLeft) . '%)'; + $score += 80; + } elseif ($qty > 0 && $pctLeft <= 30) { + if ($dailyRate > 0 && $daysLeft <= 5) { + $urgency = 'high'; + $reasons[] = 'Finisce tra ~' . round($daysLeft) . 'gg'; + $score += 75; + } elseif ($dailyRate > 0 && $daysLeft <= 10) { + $urgency = 'medium'; + $reasons[] = 'Finisce tra ~' . round($daysLeft) . 'gg'; + $score += 50; + } else { + $urgency = 'low'; + $reasons[] = 'Scorta bassa (' . round($pctLeft) . '%)'; + $score += 30; + } + } + + // Expiring soon or expired (needs replacement) + if ($isExpired && $qty > 0) { + $urgency = 'critical'; + $reasons[] = 'Scaduto!'; + $score += 90; + } elseif ($isExpiringSoon && $qty > 0) { + if ($urgency === 'none') $urgency = 'medium'; + $reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg'; + $score += 40; + } + + // Frequently used but stock getting low (predictive) + if ($urgency === 'none' && $dailyRate > 0 && $daysLeft <= 14 && $useCount >= 3) { + $urgency = 'low'; + $reasons[] = 'Previsto esaurimento tra ~' . round($daysLeft) . 'gg'; + $score += 25; + } + + // Opened item with fast consumption + if ($isOpened && $urgency === 'none' && $dailyRate > 0 && $daysLeft <= 7) { + $urgency = 'low'; + $reasons[] = 'Aperto, finisce presto'; + $score += 20; + } + + if ($urgency === 'none') continue; + + // Boost score for very frequent items + if ($useCount >= 8) $score += 15; + elseif ($useCount >= 5) $score += 10; + + // Is already on Bring? + $onBring = isset($bringItems[mb_strtolower($p['name'])]); + + $items[] = [ + 'product_id' => $pid, + 'name' => $p['name'], + 'brand' => $p['brand'] ?: '', + 'category' => $p['category'] ?: '', + 'unit' => $unit, + 'current_qty' => round($qty, 1), + 'default_qty' => $defQty, + 'package_unit' => $p['package_unit'] ?: '', + 'pct_left' => round($pctLeft), + 'use_count' => $useCount, + 'buy_count' => $buyCount, + 'daily_rate' => round($dailyRate, 2), + 'days_left' => round($daysLeft), + 'expiry_date' => $expiryDate, + 'days_to_expiry' => round($daysToExpiry), + 'is_opened' => $isOpened, + 'urgency' => $urgency, + 'reasons' => $reasons, + 'score' => $score, + 'on_bring' => $onBring, + 'locations' => $inv ? $inv['locations'] : '', + ]; + } + + // Sort by score descending (most urgent first) + usort($items, fn($a, $b) => $b['score'] - $a['score']); + + echo json_encode(['success' => true, 'items' => $items], JSON_UNESCAPED_UNICODE); +} + function bringSuggestItems(PDO $db): void { $env = loadEnvVars(); $apiKey = $env['GEMINI_API_KEY'] ?? ''; diff --git a/assets/css/style.css b/assets/css/style.css index c7a3da2..b2b2dba 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -243,6 +243,7 @@ body { display: flex; flex-direction: column; gap: 4px; + position: relative; } .stat-card:active { @@ -1155,6 +1156,204 @@ body { gap: 16px; } +/* ===== SHOPPING TABS ===== */ +.shopping-tabs { + display: flex; + gap: 6px; + background: var(--bg-card); + border-radius: var(--radius); + padding: 4px; + box-shadow: var(--shadow); +} + +.shopping-tab { + flex: 1; + padding: 9px 8px; + border-radius: calc(var(--radius) - 2px); + border: none; + background: transparent; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; +} + +.shopping-tab.active { + background: var(--accent); + color: #fff; + box-shadow: 0 2px 6px rgba(45, 80, 22, 0.25); +} + +.shopping-tab-count { + background: rgba(255,255,255,0.25); + border-radius: 10px; + padding: 1px 6px; + font-size: 0.75rem; + font-weight: 700; +} + +.shopping-tab:not(.active) .shopping-tab-count { + background: var(--bg); + color: var(--accent); +} + +.tab-panel-shopping { + display: none; +} + +.tab-panel-shopping.active { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ===== SHOPPING ITEM ENHANCEMENTS ===== */ +.shopping-item { + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.shopping-item:active { + transform: scale(0.99); + background: #f0fdf4; +} + +.shopping-item-name-row { + display: flex; + align-items: center; + gap: 6px; +} + +.shopping-item-scan-hint { + font-size: 0.75rem; + opacity: 0.4; + flex-shrink: 0; +} + +.shopping-item-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 3px; +} + +/* Generic small item badge (sinv = shopping inventory) */ +.sinv-badge { + display: inline-block; + font-size: 0.62rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + white-space: nowrap; + line-height: 1.6; +} + +.badge-critical { background: #fee2e2; color: #dc2626; } +.badge-high { background: #ffedd5; color: #c2410c; } +.badge-medium { background: #fef9c3; color: #a16207; } +.badge-low { background: #dcfce7; color: #15803d; } + +.badge-freq-high { background: #fce7f3; color: #be185d; } +.badge-freq-med { background: #ede9fe; color: #6d28d9; } +.badge-freq-low { background: #f1f5f9; color: #64748b; } + +.badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; } +.badge-local-tag:hover { background: #bae6fd; } + +/* Tag add button */ +.shopping-item-tag-btn { + background: none; + border: none; + font-size: 0.75rem; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + opacity: 0.5; + transition: opacity 0.2s; +} +.shopping-item-tag-btn:hover { opacity: 1; } + +/* Tag menu dropdown */ +.shopping-tag-menu-container { + margin-top: 6px; + padding: 6px 8px; + background: #f8fafc; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.shopping-tag-menu { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.badge-tag-add { + background: #f1f5f9; + color: var(--text-secondary); + border: 1px solid var(--border); + cursor: pointer; + transition: all 0.15s; + padding: 2px 8px; +} + +.badge-tag-add:hover, .badge-tag-add.active { + background: #e0f2fe; + color: #0369a1; + border-color: #7dd3fc; +} + +/* ===== SHOPPING SCAN TARGET BANNER ===== */ +.shopping-scan-target-banner { + background: linear-gradient(135deg, #FFF9C4, #FFF3CD); + border: 1.5px solid #F59E0B; + border-radius: var(--radius); + padding: 12px 14px; + margin: 0 0 4px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.shopping-scan-target-info { + display: flex; + align-items: center; + gap: 8px; +} + +.stb-label { + font-size: 0.8rem; + color: #92400e; + font-weight: 600; + white-space: nowrap; +} + +.stb-name { + font-size: 0.95rem; + font-weight: 700; + color: #78350f; +} + +.shopping-scan-target-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.stb-btn { + flex: 1; + padding: 8px 10px; + font-size: 0.82rem; + min-width: 120px; +} + + .bring-loading { text-align: center; padding: 30px; @@ -1213,6 +1412,7 @@ body { background: var(--bg-card); border-radius: var(--radius); box-shadow: var(--shadow); + /* tap-to-scan handled by JS; cursor/active defined in ENHANCEMENTS block above */ } .shopping-item-icon { @@ -1453,6 +1653,244 @@ body { width: 100%; } +/* ===== SMART SHOPPING ===== */ +.smart-shopping { + margin-top: 16px; +} + +.smart-shopping h3 { + font-size: 1rem; + font-weight: 700; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.smart-shopping h3 .shopping-count { + font-size: 0.75rem; + background: var(--accent); + color: #fff; + border-radius: 10px; + padding: 1px 7px; + font-weight: 600; +} + +.smart-filter-row { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 8px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} + +.smart-filter-row::-webkit-scrollbar { + display: none; +} + +.smart-filter { + flex-shrink: 0; + padding: 5px 12px; + border-radius: 16px; + border: 1.5px solid var(--border); + background: var(--bg-card); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.smart-filter.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.smart-items { + display: flex; + flex-direction: column; + gap: 6px; +} + +.smart-item { + padding: 10px 12px; + border-radius: var(--radius); + box-shadow: var(--shadow); + transition: transform 0.1s; +} + +.smart-item:active { + transform: scale(0.99); +} + +.smart-item-top { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.smart-check { + margin-top: 3px; + width: 18px; + height: 18px; + accent-color: var(--accent); + flex-shrink: 0; + cursor: pointer; +} + +.smart-item-icon { + font-size: 1.4rem; + flex-shrink: 0; + line-height: 1; + margin-top: 1px; +} + +.smart-item-info { + flex: 1; + min-width: 0; +} + +.smart-item-name { + font-weight: 600; + font-size: 0.9rem; + line-height: 1.3; +} + +.smart-brand { + font-weight: 400; + color: var(--text-muted); + font-size: 0.78rem; +} + +.smart-item-reasons { + font-size: 0.72rem; + color: var(--text-muted); + margin-top: 2px; + line-height: 1.4; +} + +.smart-item-reasons span + span::before { + content: ''; +} + +.smart-item-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 5px; +} + +.smart-urgency-badge, +.smart-freq-badge, +.smart-pred-badge, +.smart-bring-badge { + display: inline-block; + font-size: 0.65rem; + font-weight: 600; + padding: 1px 7px; + border-radius: 8px; + white-space: nowrap; +} + +.smart-urgency-badge { + background: #f3f4f6; +} + +.smart-freq-badge { + background: #ede9fe; + color: #6d28d9; +} + +.smart-freq-badge.freq-high { + background: #fce7f3; + color: #be185d; +} + +.smart-freq-badge.freq-med { + background: #ede9fe; + color: #6d28d9; +} + +.smart-freq-badge.freq-low { + background: #f0fdf4; + color: #15803d; +} + +.smart-pred-badge { + background: #fefce8; + color: #a16207; +} + +.smart-pred-badge.pred-urgent { + background: #fee2e2; + color: #dc2626; +} + +.smart-pred-badge.pred-soon { + background: #fff7ed; + color: #c2410c; +} + +.smart-bring-badge { + background: #dbeafe; + color: #1d4ed8; +} + +.smart-item-stock { + text-align: right; + flex-shrink: 0; + min-width: 50px; +} + +.smart-qty { + font-size: 0.8rem; + font-weight: 700; + display: block; +} + +.smart-stock-bar { + width: 44px; + height: 5px; + background: #e5e7eb; + border-radius: 3px; + overflow: hidden; + margin-top: 4px; + margin-left: auto; +} + +.smart-stock-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s; +} + +.smart-actions { + margin-top: 12px; + text-align: center; +} + +.smart-actions .btn { + width: 100%; + font-size: 0.85rem; + padding: 10px; +} + +.stat-urgent { + position: absolute; + top: 4px; + right: 4px; + background: #ef4444; + color: #fff; + font-size: 0.6rem; + font-weight: 700; + padding: 1px 5px; + border-radius: 8px; + line-height: 1.3; +} + /* ===== PRODUCT PREVIEW ===== */ .product-preview, .product-preview-small { background: var(--bg-card); @@ -2137,6 +2575,16 @@ body { white-space: nowrap; } +.opened-badge { + font-size: 0.7rem; + background: rgba(245, 158, 11, 0.12); + color: #d97706; + padding: 1px 6px; + border-radius: 8px; + font-weight: 600; + white-space: nowrap; +} + /* ===== REMAINING QUANTITY OPTIONS ===== */ .remaining-options { display: grid; @@ -3763,3 +4211,34 @@ body { .screensaver-fact.visible { opacity: 1; } +.screensaver-shortcuts { + position: absolute; + bottom: max(32px, env(safe-area-inset-bottom, 32px)); + right: 32px; + display: flex; + flex-direction: column; + gap: 16px; + z-index: 10001; +} +.screensaver-shortcut-btn { + width: 64px; + height: 64px; + border-radius: 50%; + border: 2px solid rgba(255,255,255,0.25); + background: rgba(255,255,255,0.1); + color: white; + font-size: 1.8rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: background 0.2s, transform 0.15s; + user-select: none; + -webkit-user-select: none; +} +.screensaver-shortcut-btn:active { + background: rgba(255,255,255,0.25); + transform: scale(0.92); +} diff --git a/assets/js/app.js b/assets/js/app.js index 0d1ab5c..c14c623 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -274,8 +274,11 @@ function estimateExpiryDays(product, location) { else if (/mozzarella|burrata|stracciatella/.test(name)) days = 5; else if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) days = 10; else if (/parmigiano|grana|pecorino|provolone/.test(name)) days = 60; + else if (/burro/.test(name)) days = 60; + else if (/panna/.test(name)) days = 14; else if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) days = 7; else if (/prosciutto\s+crudo|salame|bresaola|speck/.test(name)) days = 30; + else if (/nduja/.test(name)) days = 90; else if (/uova/.test(name)) days = 28; else if (/pane\s+fresco|pane\s+in\s+cassetta/.test(name)) days = 5; else if (/pane\s+confezionato|pan\s+carr|pancarrè/.test(name)) days = 14; @@ -288,6 +291,14 @@ function estimateExpiryDays(product, location) { else if (/succo|spremuta/.test(name)) days = 7; else if (/birra|vino/.test(name)) days = 365; else if (/acqua/.test(name)) days = 365; + else if (/mela|mele\b/.test(name)) days = 7; + else if (/arancia|arance|mandarini|agrumi/.test(name)) days = 7; + else if (/banana|banane/.test(name)) days = 5; + else if (/pera|pere\b|fragola|fragole|uva|kiwi/.test(name)) days = 5; + else if (/carota|carote|zucchina|zucchine|peperoni|melanzane/.test(name)) days = 7; + else if (/broccoli|cavolfiore|cavolo|spinaci|bietola/.test(name)) days = 5; + else if (/cipolla|cipolle/.test(name)) days = 10; + else if (/patata|patate/.test(name)) days = 14; else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180; else if (/nutella|marmellata|miele/.test(name)) days = 365; else if (/passata|pelati|pomodor/.test(name)) days = 730; @@ -300,6 +311,28 @@ function estimateExpiryDays(product, location) { } } + // Fridge extends shelf life for produce and short-lived items (sealed only) + if (loc === 'frigo') { + // Specific fridge-friendly produce overrides + if (/mela|mele/.test(name)) days = Math.max(days, 28); + else if (/arancia|arance|agrumi|mandarini|limone|limoni/.test(name)) days = Math.max(days, 21); + else if (/carota|carote/.test(name)) days = Math.max(days, 21); + else if (/cipolla/.test(name)) days = Math.max(days, 14); + else if (/patata|patate/.test(name)) days = Math.max(days, 21); + else if (/pera|pere/.test(name)) days = Math.max(days, 21); + else if (/kiwi/.test(name)) days = Math.max(days, 28); + else if (/uva/.test(name)) days = Math.max(days, 14); + else if (/fragola|fragole/.test(name)) days = Math.max(days, 7); + else if (/peperoni/.test(name)) days = Math.max(days, 14); + else if (/zucchina|zucchine/.test(name)) days = Math.max(days, 14); + else if (/melanzane/.test(name)) days = Math.max(days, 14); + else if (/broccoli|cavolfiore|cavolo/.test(name)) days = Math.max(days, 10); + // General fridge bonus: fruits and vegs that aren't already long + else if (days <= 7 && (/frutta|fruit/.test(cat) || /verdur|vegetable|plant-based/.test(cat))) { + days = Math.round(days * 2); // ~double shelf life in fridge + } + } + // Freezer extends shelf life significantly if (loc === 'freezer' && days < 180) { // Fresh meat/fish: 3-6 months in freezer @@ -322,6 +355,60 @@ function formatEstimatedExpiry(days) { return `~${Math.round(days / 365)} anni`; } +/** + * Estimate shelf life in days for an OPENED product. + * Much shorter than sealed shelf life — based on typical "once opened, consume within X days". + */ +function estimateOpenedExpiryDays(product, location) { + const name = (product.name || '').toLowerCase(); + const cat = (product.category || '').toLowerCase(); + const loc = (location || '').toLowerCase(); + + // Freezer: opened items still last a long time + if (loc === 'freezer') return 90; + // Dispensa: opened dry goods + if (loc === 'dispensa') return 30; + + // Specific product overrides (fridge) + if (/latte\s+(fresco|intero|parzial|scremato)/.test(name)) return 3; + if (/latte\s+uht|latte\s+a\s+lunga/.test(name)) return 5; + if (/latte/.test(name)) return 4; + if (/yogurt/.test(name)) return 3; + if (/mozzarella|burrata|stracciatella/.test(name)) return 2; + if (/philadelphia|spalmabile/.test(name)) return 7; + if (/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 5; + if (/parmigiano|grana|pecorino|provolone/.test(name)) return 21; + if (/formaggio/.test(name)) return 10; + if (/burro/.test(name)) return 21; + if (/panna/.test(name)) return 3; + if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) return 3; + if (/prosciutto\s+crudo|salame|bresaola|speck|pancetta|nduja/.test(name)) return 7; + if (/pollo|tacchino|maiale|manzo|vitello/.test(name)) return 2; + if (/salmone|tonno\s+fresco|pesce/.test(name)) return 2; + if (/passata|pelati|polpa|sugo/.test(name)) return 5; + if (/marmellata|confettura/.test(name)) return 30; + if (/miele/.test(name)) return 180; + if (/nutella/.test(name)) return 60; + if (/succo|spremuta/.test(name)) return 4; + if (/olio|aceto/.test(name)) return 90; + if (/vino|birra/.test(name)) return 5; + if (/limone|limmi/.test(name)) return 21; + if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) return 3; + if (/insalata|rucola|spinaci/.test(name)) return 3; + + // Category fallbacks + if (/dairy|latticin|lait|dairies/.test(cat)) return 5; + if (/meat|carne|meats/.test(cat)) return 3; + if (/fish|pesce/.test(cat)) return 2; + if (/fruit|frutta/.test(cat)) return 5; + if (/verdur|vegetable|plant-based/.test(cat)) return 5; + if (/conserve/.test(cat)) return 5; + if (/condimenti|sauce/.test(cat)) return 21; + if (/bevand|beverage/.test(cat)) return 4; + + return 5; // safe default for fridge +} + function addDays(days) { const d = new Date(); d.setDate(d.getDate() + days); @@ -1270,6 +1357,7 @@ function renderInventoryItem(item) { } const vacuumBadge = item.vacuum_sealed ? '🫙 Sotto vuoto' : ''; + const openedBadge = item.opened_at ? '📭 Aperto' : ''; return `
@@ -1282,6 +1370,7 @@ function renderInventoryItem(item) {
${locInfo.icon} ${locInfo.label} ${expiryBadge} + ${openedBadge} ${vacuumBadge}
@@ -1366,6 +1455,11 @@ function showItemDetail(inventoryId, productId) { 🫙 Conservazione Sotto vuoto ` : ''} + ${item.opened_at ? ` + ` : ''} ${item.barcode ? ` `; spesaBar = `
- - 🔗 Apri + + 🔗 Apri
`; } else if (priceData && priceData.searched && !priceData.product) { detailHtml = `
Non trovato
`; spesaBar = `
- +
`; } else { spesaBar = `
- +
`; } } return ` -
+
${catIcon}
-
${escapeHtml(item.name)}
+
+ ${escapeHtml(item.name)} + 📷 +
${item.specification ? `
${escapeHtml(item.specification)}
` : ''} + ${(urgencyBadge || freqBadge || localTagHtml) ? `
${urgencyBadge}${freqBadge}${localTagHtml}
` : ''} ${detailHtml}
-
+
${priceTag} +
${spesaBar} +
`; }).join(''); @@ -4296,6 +4741,15 @@ async function renderShoppingItems() { updateSpesaTotal(); } +function toggleShoppingTagMenu(btn) { + const container = btn.closest('.shopping-item-body').querySelector('.shopping-tag-menu-container'); + if (!container) return; + const isOpen = container.style.display !== 'none'; + // Close all other menus first + document.querySelectorAll('.shopping-tag-menu-container').forEach(c => c.style.display = 'none'); + container.style.display = isOpen ? 'none' : 'block'; +} + function updateSpesaTotal() { const banner = document.getElementById('spesa-total-banner'); const valueEl = document.getElementById('spesa-total-value'); @@ -5723,7 +6177,7 @@ function updateScreensaverClock() { if (el) el.innerHTML = `${time}
${date}
`; } -function dismissScreensaver() { +function dismissScreensaver(targetPage) { if (!_screensaverActive) return; clearInterval(_screensaverClockInterval); clearInterval(_screensaverFactInterval); @@ -5733,8 +6187,11 @@ function dismissScreensaver() { overlay.style.display = 'none'; _screensaverActive = false; _screensaverData = null; - // Reload all data for the current page - refreshCurrentPage(); + if (targetPage) { + showPage(targetPage); + } else { + refreshCurrentPage(); + } resetInactivityTimer(); }, 400); } @@ -6161,6 +6618,45 @@ function spesaModeAfterAdd() { return true; } +function _initScreensaverShortcutBtn(btnId, targetPage, longPressFn) { + const btn = document.getElementById(btnId); + if (!btn) return; + let ssLongPress = null; + btn.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + if (longPressFn) { + ssLongPress = setTimeout(() => { + ssLongPress = null; + dismissScreensaver(targetPage); + setTimeout(longPressFn, 500); + }, 600); + } + }); + btn.addEventListener('pointerup', (e) => { + e.stopPropagation(); + if (longPressFn && ssLongPress) { + clearTimeout(ssLongPress); + ssLongPress = null; + } + dismissScreensaver(targetPage); + }); + btn.addEventListener('pointerleave', (e) => { + e.stopPropagation(); + if (ssLongPress) { + clearTimeout(ssLongPress); + ssLongPress = null; + } + }); + ['click', 'touchstart', 'touchend'].forEach(evt => { + btn.addEventListener(evt, (e) => e.stopPropagation(), { passive: false }); + }); +} + +function initScreensaverShortcuts() { + _initScreensaverShortcutBtn('screensaver-scan-btn', 'scan', () => startSpesaMode()); + _initScreensaverShortcutBtn('screensaver-recipe-btn', 'recipe', null); +} + function initInactivityWatcher() { const events = ['pointerdown', 'pointermove', 'keydown', 'scroll', 'touchstart']; events.forEach(evt => { @@ -6181,6 +6677,7 @@ document.addEventListener('DOMContentLoaded', () => { showPage('dashboard'); initInactivityWatcher(); initSpesaMode(); + initScreensaverShortcuts(); }); // ===== DUPLICLICK (SPESA ONLINE) ===== diff --git a/data/dispensa.db b/data/dispensa.db index 09c300df5b7da5dafb60728990f609fabcfecd4d..5d32e04ee552a4bdc4c45b374dbc0f9ce766e4b4 100644 GIT binary patch delta 865 zcmZ{iO-vI(6vyYyesp)CosdQ;L0T$U;Lxz$7HF#?TO!5~zr~XQC1O((0f|wFA+T!v zh$fa)GKmQ$eqrLxAlXZ!)u=}e#;ZrK+7R4AiQsl?k&DU99A^IS|9fxVo6L~O3`tho zqX?mPb4Sb#Q;ls9eR=B9PaC(0>Y2}FDIwd1`PXxYvyVB5nfSS&ZB;rGZimbHyWpjm74x@37$*l=C`~xY_#7_&*0R7W4C}mPLet`$D z7rsI2!qK$e1n>c{{i;QvG6Pj?(65B_0}OoP-LZH9gDuRUg@L6aC*qeQRARtK%=}|} z|Ht-T zZJSbEwVj%y5Kych-L(Qxz8KNs4}|D@1AJ~(vlmpN^+$+Ws*kF(1-V!{+sh&tj~Ly?%s`|YQB#-ol7#ShyDNq C#q0h6 delta 740 zcmY+BL1@!p6vyBDlD7G>b|2dgR@T|YX}1Z%tgUN%ajDY57|Kv59(0JHLl0XOK|#h= z2M)nQOPn8g7$~}fH=QMyF?P{|=tS`}@!-{i7SWVd=KPYb0|N<3-uHk1-}}FrRW`HA zitV8c02~&#ca?fln0bEw(T{-t7VMMWQ%k7~65r3Ik;-FV#O$VkyHHCcl-^6%E{{)6 z#Ep2rZY1=0()#T5Sp80p_*bkcCo2bDL(VGD?2=Uds|3_@5XW9=Ucqi`pEoexM81<} zCr#CRn z1ah_zY`9W>riy7F&{Gg8Wy8E!MW@3ogxosyOt{x!2P)3@S1}e9J*ct`0@i;V{d!-L zw{BsKH*eseOxNM4;2&r*3w~GOuAjf%5K%YXfCHiuv*j9oBseSHr|r|5cowAOkJzqt`sTi75iWD+JA#MDA1&%YbF}!o}dj no#d0))PuRQM(naC4UefIkqNQ|U<*qv#ruq()X0U!qBQak2PVTq diff --git a/index.html b/index.html index 5bd42e1..412dadd 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ Dispensa Manager - + @@ -55,6 +55,7 @@ 🛒 - Spesa +
@@ -168,9 +169,11 @@
+ +
@@ -513,40 +516,86 @@
Connessione a Bring!...
- -
@@ -909,6 +958,14 @@
+
+ + +