Smart shopping: cron ogni 5min pre-calcola cache server-side, API serve da cache (risposta istantanea)

This commit is contained in:
dadaloop82
2026-04-01 05:52:17 +00:00
parent 200ec145d9
commit e18fb5839a
3 changed files with 150 additions and 22 deletions
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Cron: pre-compute smart shopping list and save to cache.
* Install with: crontab -e
* *\/5 * * * * php /var/www/html/dispensa/api/cron_smart_shopping.php >> /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);
}
+60 -9
View File
@@ -4,6 +4,12 @@
* Handles all CRUD operations for products and inventory * 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('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
@@ -14,8 +20,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit; exit;
} }
require_once __DIR__ . '/database.php';
try { try {
$db = getDB(); $db = getDB();
} catch (Exception $e) { } catch (Exception $e) {
@@ -27,6 +31,9 @@ try {
$method = $_SERVER['REQUEST_METHOD']; $method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
} // end !CRON_MODE block for router bootstrap
if (!defined('CRON_MODE')):
try { try {
switch ($action) { switch ($action) {
// ===== PRODUCTS ===== // ===== PRODUCTS =====
@@ -116,7 +123,7 @@ try {
bringSuggestItems($db); bringSuggestItems($db);
break; break;
case 'smart_shopping': case 'smart_shopping':
smartShopping($db); smartShoppingCached($db);
break; break;
case 'save_settings': case 'save_settings':
@@ -192,6 +199,7 @@ try {
http_response_code(500); http_response_code(500);
echo json_encode(['error' => $e->getMessage()]); echo json_encode(['error' => $e->getMessage()]);
} }
endif; // end !CRON_MODE
// ===== CLIENT LOG ===== // ===== CLIENT LOG =====
@@ -1426,17 +1434,31 @@ function generateRecipe(PDO $db): void {
} }
$ingredientsText = implode("\n\n", $ingredientSections); $ingredientsText = implode("\n\n", $ingredientSections);
// Build a mandatory-use list from the most urgent items (groups 1 + 2) // Build mandatory/recommended lists:
$urgentItems = []; // - 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) { foreach ($items as $item) {
$g = $getItemPriority($item); $g = $getItemPriority($item);
if ($g <= 2) { $daysLeft = floatval($item['days_left']);
$urgentItems[] = $item['name'] . ($item['brand'] ? " ({$item['brand']})" : '') . " — scade: {$item['expiry_date']}"; $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 = ''; $mustUseText = '';
if (!empty($urgentItems)) { if (!empty($mandatoryItems)) {
$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)); $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 = [ $mealLabels = [
@@ -1532,6 +1554,7 @@ REGOLE IMPORTANTI:
f) PRODOTTI CHIUSI SENZA SCADENZA: usa per ultimi 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à. 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. *** *** 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 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 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 4. Adatta le quantità per $persons persona/e
@@ -2250,6 +2273,34 @@ function bringCleanSpecs(): void {
echo json_encode(['success' => true, 'cleaned' => $cleaned]); 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 * Smart Shopping List: analyzes usage frequency, stock levels, expiry to produce
* intelligent urgency-ranked shopping recommendations. * intelligent urgency-ranked shopping recommendations.
+44 -13
View File
@@ -4513,17 +4513,48 @@ function estimateItemPrice(product, spec) {
// ===== SMART SHOPPING ===== // ===== SMART SHOPPING =====
let smartShoppingItems = []; let smartShoppingItems = [];
let smartShoppingFilter = 'all'; 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() { async function loadSmartShopping() {
try { try {
const data = await api('smart_shopping'); const data = await api('smart_shopping');
if (data.success && data.items && data.items.length > 0) { if (data.success && data.items && data.items.length > 0) {
smartShoppingItems = data.items; smartShoppingItems = data.items;
_smartShoppingLastFetch = Date.now();
renderSmartShopping(); renderSmartShopping();
_renderSmartLastUpdate();
_updateSmartUrgencyBadge();
document.getElementById('smart-shopping-empty').style.display = 'none'; document.getElementById('smart-shopping-empty').style.display = 'none';
document.getElementById('smart-shopping-content').style.display = 'block'; document.getElementById('smart-shopping-content').style.display = 'block';
} else { } else {
smartShoppingItems = []; smartShoppingItems = [];
_smartShoppingLastFetch = Date.now();
document.getElementById('smart-shopping-empty').style.display = 'block'; document.getElementById('smart-shopping-empty').style.display = 'block';
document.getElementById('smart-shopping-content').style.display = 'none'; document.getElementById('smart-shopping-content').style.display = 'none';
} }
@@ -4713,20 +4744,19 @@ async function loadShoppingCount() {
} catch { } catch {
document.getElementById('stat-spesa').textContent = '-'; document.getElementById('stat-spesa').textContent = '-';
} }
// Smart urgency badge // Smart urgency badge: use cached data if fresh (< 2 min), else fetch
try { if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
const smart = await api('smart_shopping'); _updateSmartUrgencyBadge();
const urgentEl = document.getElementById('stat-urgent'); } else {
if (smart.success && smart.items) { try {
const urgent = smart.items.filter(i => i.urgency === 'critical' || i.urgency === 'high').length; const smart = await api('smart_shopping');
if (urgent > 0) { if (smart.success && smart.items) {
urgentEl.textContent = `${urgent}`; smartShoppingItems = smart.items;
urgentEl.style.display = ''; _smartShoppingLastFetch = Date.now();
} else { _updateSmartUrgencyBadge();
urgentEl.style.display = 'none';
} }
} } catch { /* ignore */ }
} catch { /* ignore */ } }
} }
async function loadShoppingList() { async function loadShoppingList() {
@@ -7290,6 +7320,7 @@ document.addEventListener('DOMContentLoaded', () => {
initInactivityWatcher(); initInactivityWatcher();
initSpesaMode(); initSpesaMode();
initScreensaverShortcuts(); initScreensaverShortcuts();
startBgShoppingRefresh();
}); });
// ===== DUPLICLICK (SPESA ONLINE) ===== // ===== DUPLICLICK (SPESA ONLINE) =====