diff --git a/api/cron_smart_shopping.php b/api/cron_smart_shopping.php new file mode 100644 index 0000000..64b1718 --- /dev/null +++ b/api/cron_smart_shopping.php @@ -0,0 +1,46 @@ +> /var/www/html/dispensa/data/cron.log 2>&1 + */ + +// Only allow CLI execution — block HTTP access +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit('Forbidden'); +} + +// Define CRON_MODE before loading index.php so the router is skipped +define('CRON_MODE', true); + +// Load all API functions without running the HTTP router +require_once __DIR__ . '/index.php'; + +const CACHE_FILE = __DIR__ . '/../data/smart_shopping_cache.json'; + +try { + $db = getDB(); + + // Capture the JSON output of smartShopping() + ob_start(); + smartShopping($db); + $json = ob_get_clean(); + + $decoded = json_decode($json, true); + if (!$decoded || !isset($decoded['success'])) { + throw new RuntimeException('Invalid JSON from smartShopping(): ' . substr($json, 0, 200)); + } + + $decoded['cached_at'] = date('c'); + $decoded['cached_ts'] = time(); + + if (file_put_contents(CACHE_FILE, json_encode($decoded, JSON_UNESCAPED_UNICODE)) === false) { + throw new RuntimeException('Cannot write cache file: ' . CACHE_FILE); + } + + echo '[' . date('Y-m-d H:i:s') . '] OK — ' . count($decoded['items'] ?? []) . " items cached\n"; +} catch (Throwable $e) { + echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $e->getMessage() . "\n"; + exit(1); +} diff --git a/api/index.php b/api/index.php index 7eb92bc..4ee834e 100644 --- a/api/index.php +++ b/api/index.php @@ -4,6 +4,12 @@ * Handles all CRUD operations for products and inventory */ +// database.php must always be loaded (used both by HTTP router and cron) +require_once __DIR__ . '/database.php'; + +// When included by the cron script, skip HTTP headers and routing entirely +if (!defined('CRON_MODE')) { + header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); @@ -14,8 +20,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; } -require_once __DIR__ . '/database.php'; - try { $db = getDB(); } catch (Exception $e) { @@ -27,6 +31,9 @@ try { $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; +} // end !CRON_MODE block for router bootstrap + +if (!defined('CRON_MODE')): try { switch ($action) { // ===== PRODUCTS ===== @@ -116,7 +123,7 @@ try { bringSuggestItems($db); break; case 'smart_shopping': - smartShopping($db); + smartShoppingCached($db); break; case 'save_settings': @@ -192,6 +199,7 @@ try { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } +endif; // end !CRON_MODE // ===== CLIENT LOG ===== @@ -1426,17 +1434,31 @@ function generateRecipe(PDO $db): void { } $ingredientsText = implode("\n\n", $ingredientSections); - // Build a mandatory-use list from the most urgent items (groups 1 + 2) - $urgentItems = []; + // Build mandatory/recommended lists: + // - Truly mandatory: expired (group 1) OR expiring within 1 day (group 2 + daysLeft ≤ 1) + // These are genuinely at risk of being wasted RIGHT NOW. + // - Highly recommended: expiring in 2-3 days (group 2 + daysLeft > 1) + // Fresh products like milk naturally have short shelf lives and are often used anyway, + // so we suggest rather than force them. + $mandatoryItems = []; + $recommendedItems = []; foreach ($items as $item) { $g = $getItemPriority($item); - if ($g <= 2) { - $urgentItems[] = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . " — scade: {$item['expiry_date']}"; + $daysLeft = floatval($item['days_left']); + $label = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . " — scade: {$item['expiry_date']}"; + if ($g === 1 || ($g === 2 && $daysLeft <= 1)) { + $mandatoryItems[] = $label; + } elseif ($g === 2) { + $recommendedItems[] = $label; } } + $mustUseText = ''; - if (!empty($urgentItems)) { - $mustUseText = "\n\n⚠️⚠️⚠️ INGREDIENTI OBBLIGATORI (SCADUTI O IN SCADENZA IMMINENTE) ⚠️⚠️⚠️\nLa ricetta DEVE usare ALMENO uno (meglio se tutti) di questi ingredienti come ingrediente PRINCIPALE della ricetta. Non sono opzionali!\n" . implode("\n", array_map(fn($n) => "→ $n", $urgentItems)); + if (!empty($mandatoryItems)) { + $mustUseText .= "\n\n⚠️⚠️⚠️ INGREDIENTI OBBLIGATORI (SCADUTI O IN SCADENZA OGGI/DOMANI) ⚠️⚠️⚠️\nLa ricetta DEVE usare ALMENO uno (meglio se tutti) di questi ingredienti come ingrediente PRINCIPALE. Non sono opzionali!\n" . implode("\n", array_map(fn($n) => "→ $n", $mandatoryItems)); + } + if (!empty($recommendedItems)) { + $mustUseText .= "\n\n🔶 INGREDIENTI FORTEMENTE CONSIGLIATI (scadono tra 2-3 giorni)\nCerca di includerli se la ricetta lo permette, ma non è obbligatorio se non si abbinano bene:\n" . implode("\n", array_map(fn($n) => "· $n", $recommendedItems)); } $mealLabels = [ @@ -1532,6 +1554,7 @@ REGOLE IMPORTANTI: f) PRODOTTI CHIUSI SENZA SCADENZA: usa per ultimi Costruisci la ricetta partendo dagli ingredienti delle categorie più urgenti! Usa il maggior numero possibile di ingredienti ad alta priorità. *** OBBLIGO: se nella sezione "INGREDIENTI OBBLIGATORI" sopra ci sono prodotti, la ricetta DEVE contenere ALMENO UNO di quei prodotti come ingrediente principale. Se li ignori, la ricetta è SBAGLIATA. *** + *** CONSIGLIO: gli "INGREDIENTI FORTEMENTE CONSIGLIATI" vanno usati se si abbinano bene alla ricetta, ma puoi escluderli se non si adattano — spesso sono prodotti freschi come il latte che si usano comunque. *** 2. Prediligi una ricetta SANA, EQUILIBRATA e NUTRIENTE 3. Usa SOLO ingredienti dalla lista sotto, più al massimo acqua, sale, pepe e olio che si presumono sempre disponibili 4. Adatta le quantità per $persons persona/e @@ -2250,6 +2273,34 @@ function bringCleanSpecs(): void { echo json_encode(['success' => true, 'cleaned' => $cleaned]); } +/** + * Serve smart shopping from cache (written by cron), falling back to live computation. + * Cache is valid for up to 10 minutes; if stale or missing, compute on the fly. + */ +function smartShoppingCached(PDO $db): void { + $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; + $maxAge = 10 * 60; // 10 minutes + + if (file_exists($cacheFile)) { + $mtime = filemtime($cacheFile); + if ((time() - $mtime) <= $maxAge) { + $raw = file_get_contents($cacheFile); + if ($raw !== false) { + // Inject how many seconds ago the cache was created + $data = json_decode($raw, true); + if ($data && isset($data['success'])) { + $data['cache_age_seconds'] = time() - ($data['cached_ts'] ?? $mtime); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + return; + } + } + } + } + + // Cache missing or stale — compute live + smartShopping($db); +} + /** * Smart Shopping List: analyzes usage frequency, stock levels, expiry to produce * intelligent urgency-ranked shopping recommendations. diff --git a/assets/js/app.js b/assets/js/app.js index f906ea9..f0e28b9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -4513,17 +4513,48 @@ function estimateItemPrice(product, spec) { // ===== SMART SHOPPING ===== let smartShoppingItems = []; let smartShoppingFilter = 'all'; +let _smartShoppingLastFetch = 0; // timestamp of last successful fetch +let _bgShoppingInterval = null; // kept for compatibility, cron handles refresh server-side + +/** Update dashboard badge from already-cached data */ +function _updateSmartUrgencyBadge() { + const urgentEl = document.getElementById('stat-urgent'); + if (!urgentEl) return; + const urgent = smartShoppingItems.filter(i => i.urgency === 'critical' || i.urgency === 'high').length; + if (urgent > 0) { + urgentEl.textContent = `⚠ ${urgent}`; + urgentEl.style.display = ''; + } else { + urgentEl.style.display = 'none'; + } +} + +function _renderSmartLastUpdate() { + const el = document.getElementById('smart-last-update'); + if (!el || !_smartShoppingLastFetch) return; + const d = new Date(_smartShoppingLastFetch); + el.textContent = `Aggiornato ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`; +} + +function startBgShoppingRefresh() { + // No-op: server-side cron handles refresh every 5 minutes. + // The JS fetches pre-computed cache on demand (instant response). +} async function loadSmartShopping() { try { const data = await api('smart_shopping'); if (data.success && data.items && data.items.length > 0) { smartShoppingItems = data.items; + _smartShoppingLastFetch = Date.now(); renderSmartShopping(); + _renderSmartLastUpdate(); + _updateSmartUrgencyBadge(); document.getElementById('smart-shopping-empty').style.display = 'none'; document.getElementById('smart-shopping-content').style.display = 'block'; } else { smartShoppingItems = []; + _smartShoppingLastFetch = Date.now(); document.getElementById('smart-shopping-empty').style.display = 'block'; document.getElementById('smart-shopping-content').style.display = 'none'; } @@ -4713,20 +4744,19 @@ async function loadShoppingCount() { } catch { document.getElementById('stat-spesa').textContent = '-'; } - // Smart urgency badge - try { - const smart = await api('smart_shopping'); - const urgentEl = document.getElementById('stat-urgent'); - if (smart.success && smart.items) { - const urgent = smart.items.filter(i => i.urgency === 'critical' || i.urgency === 'high').length; - if (urgent > 0) { - urgentEl.textContent = `⚠ ${urgent}`; - urgentEl.style.display = ''; - } else { - urgentEl.style.display = 'none'; + // 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 */ } + } catch { /* ignore */ } + } } async function loadShoppingList() { @@ -7290,6 +7320,7 @@ document.addEventListener('DOMContentLoaded', () => { initInactivityWatcher(); initSpesaMode(); initScreensaverShortcuts(); + startBgShoppingRefresh(); }); // ===== DUPLICLICK (SPESA ONLINE) =====