diff --git a/api/database.php b/api/database.php index f8d5a82..9bb0819 100644 --- a/api/database.php +++ b/api/database.php @@ -76,4 +76,46 @@ function migrateDB(PDO $db): void { if (!in_array('package_unit', $colNames)) { $db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''"); } + + // --- New shared tables --- + // app_settings: key-value store shared across all devices + $tables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")->fetchAll(); + if (empty($tables)) { + $db->exec(" + CREATE TABLE app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + "); + } + + // recipes: one per meal per day (last wins) + $tables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='recipes'")->fetchAll(); + if (empty($tables)) { + $db->exec(" + CREATE TABLE recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + meal TEXT NOT NULL, + recipe_json TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(date, meal) + ); + CREATE INDEX idx_recipes_date ON recipes(date); + "); + } + + // chat_messages: shared chat history + $tables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='chat_messages'")->fetchAll(); + if (empty($tables)) { + $db->exec(" + CREATE TABLE chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL, + text TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + "); + } } diff --git a/api/index.php b/api/index.php index fb957d3..5767db8 100644 --- a/api/index.php +++ b/api/index.php @@ -148,6 +148,32 @@ try { } break; + // ===== SHARED APP DATA ===== + case 'app_settings_get': + appSettingsGet($db); + break; + case 'app_settings_save': + appSettingsSave($db); + break; + case 'recipes_list': + recipesList($db); + break; + case 'recipes_save': + recipesSave($db); + break; + case 'recipes_delete': + recipesDelete($db); + break; + case 'chat_list': + chatList($db); + break; + case 'chat_save': + chatSave($db); + break; + case 'chat_clear': + chatClear($db); + break; + default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); @@ -1197,10 +1223,22 @@ function generateRecipe(PDO $db): void { $dietaryText = "\n\nRESTRIZIONI ALIMENTARI:\n{$dietaryRestrictions}\nRispetta SEMPRE queste restrizioni."; } - // Today's previous recipes - avoid repetition + // Today's previous recipes from DB - avoid repetition $todayText = ''; + $today = date('Y-m-d'); + $todayStmt = $db->prepare("SELECT recipe_json FROM recipes WHERE date = ?"); + $todayStmt->execute([$today]); + $todayDbRecipes = $todayStmt->fetchAll(); + $todayTitles = []; + foreach ($todayDbRecipes as $tr) { + $rj = json_decode($tr['recipe_json'], true); + if (!empty($rj['title'])) $todayTitles[] = $rj['title']; + } if (!empty($todayRecipes)) { - $todayList = implode(', ', array_map(function($t) { return '"' . $t . '"'; }, $todayRecipes)); + $todayTitles = array_unique(array_merge($todayTitles, $todayRecipes)); + } + if (!empty($todayTitles)) { + $todayList = implode(', ', array_map(function($t) { return '"' . $t . '"'; }, $todayTitles)); $todayText = "\n\nRICETTE GIÀ PREPARATE OGGI:\n{$todayList}\nNON proporre una ricetta simile o con lo stesso concetto di quelle già fatte oggi. Varia il tipo di piatto, gli ingredienti principali e lo stile di cucina. Ad esempio se a pranzo c'era una piadina, a cena proponi pasta, riso, zuppa o altro — MAI un'altra piadina o wrap o piatto concettualmente simile."; } @@ -2479,3 +2517,98 @@ function formatDupliclickProduct(array $p): array { return $result; } + +// ===== SHARED APP DATA FUNCTIONS ===== + +function appSettingsGet(PDO $db): void { + $rows = $db->query("SELECT key, value FROM app_settings")->fetchAll(); + $settings = []; + foreach ($rows as $row) { + $settings[$row['key']] = json_decode($row['value'], true) ?? $row['value']; + } + echo json_encode(['success' => true, 'settings' => $settings]); +} + +function appSettingsSave(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + if (!$input || !is_array($input['settings'] ?? null)) { + echo json_encode(['error' => 'Missing settings object']); + return; + } + $stmt = $db->prepare("INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"); + foreach ($input['settings'] as $key => $value) { + $stmt->execute([$key, json_encode($value)]); + } + echo json_encode(['success' => true]); +} + +function recipesList(PDO $db): void { + $limit = min(intval($_GET['limit'] ?? 60), 200); + $rows = $db->query("SELECT id, date, meal, recipe_json, created_at FROM recipes ORDER BY date DESC, created_at DESC LIMIT {$limit}")->fetchAll(); + $recipes = []; + foreach ($rows as $row) { + $recipes[] = [ + 'id' => $row['id'], + 'date' => $row['date'], + 'meal' => $row['meal'], + 'recipe' => json_decode($row['recipe_json'], true), + 'savedAt' => strtotime($row['created_at']) * 1000 + ]; + } + echo json_encode(['success' => true, 'recipes' => $recipes]); +} + +function recipesSave(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $date = $input['date'] ?? date('Y-m-d'); + $meal = $input['meal'] ?? ''; + $recipe = $input['recipe'] ?? null; + + if (!$meal || !$recipe) { + echo json_encode(['error' => 'Missing meal or recipe']); + return; + } + + // UPSERT: one recipe per meal per day (last one wins) + $stmt = $db->prepare("INSERT INTO recipes (date, meal, recipe_json, created_at) VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(date, meal) DO UPDATE SET recipe_json = excluded.recipe_json, created_at = excluded.created_at"); + $stmt->execute([$date, $meal, json_encode($recipe)]); + + echo json_encode(['success' => true, 'id' => $db->lastInsertId()]); +} + +function recipesDelete(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $id = intval($input['id'] ?? 0); + if ($id > 0) { + $db->prepare("DELETE FROM recipes WHERE id = ?")->execute([$id]); + } + echo json_encode(['success' => true]); +} + +function chatList(PDO $db): void { + $rows = $db->query("SELECT id, role, text, created_at FROM chat_messages ORDER BY id ASC LIMIT 100")->fetchAll(); + echo json_encode(['success' => true, 'messages' => $rows]); +} + +function chatSave(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true); + $messages = $input['messages'] ?? []; + if (empty($messages)) { + echo json_encode(['error' => 'No messages']); + return; + } + $stmt = $db->prepare("INSERT INTO chat_messages (role, text, created_at) VALUES (?, ?, datetime('now'))"); + foreach ($messages as $msg) { + if (!empty($msg['role']) && isset($msg['text'])) { + $stmt->execute([$msg['role'], $msg['text']]); + } + } + echo json_encode(['success' => true]); +} + +function chatClear(PDO $db): void { + $db->exec("DELETE FROM chat_messages"); + echo json_encode(['success' => true]); +} diff --git a/assets/js/app.js b/assets/js/app.js index 54882d0..d5a4aec 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -392,24 +392,77 @@ async function enumerateCameras() { } // ===== SETTINGS / CONFIG ===== +let _settingsCache = null; +let _settingsDirty = false; + function getSettings() { - try { - const s = JSON.parse(localStorage.getItem('dispensa_settings') || '{}'); - // Build recipe_prefs array from individual booleans - s.recipe_prefs = []; - if (s.pref_veloce) s.recipe_prefs.push('veloce'); - if (s.pref_pocafame) s.recipe_prefs.push('pocafame'); - if (s.pref_scadenze) s.recipe_prefs.push('scadenze'); - if (s.pref_healthy) s.recipe_prefs.push('salutare'); - if (s.pref_comfort) s.recipe_prefs.push('comfort'); - if (s.pref_zerowaste) s.recipe_prefs.push('zerowaste'); - s.dietary_restrictions = s.dietary || ''; - return s; - } catch(e) { return {}; } + if (!_settingsCache) { + try { + _settingsCache = JSON.parse(localStorage.getItem('dispensa_settings') || '{}'); + } catch(e) { _settingsCache = {}; } + } + const s = _settingsCache; + // Build recipe_prefs array from individual booleans + s.recipe_prefs = []; + if (s.pref_veloce) s.recipe_prefs.push('veloce'); + if (s.pref_pocafame) s.recipe_prefs.push('pocafame'); + if (s.pref_scadenze) s.recipe_prefs.push('scadenze'); + if (s.pref_healthy) s.recipe_prefs.push('salutare'); + if (s.pref_comfort) s.recipe_prefs.push('comfort'); + if (s.pref_zerowaste) s.recipe_prefs.push('zerowaste'); + s.dietary_restrictions = s.dietary || ''; + return s; } function saveSettingsToStorage(settings) { + _settingsCache = settings; localStorage.setItem('dispensa_settings', JSON.stringify(settings)); + // Persist to DB + _settingsDirty = true; + _debouncedSyncSettings(); +} + +const _debouncedSyncSettings = debounce(function() { + if (!_settingsDirty) return; + _settingsDirty = false; + const s = getSettings(); + // Don't sync secrets or device-specific settings to shared DB + const shared = { + default_persons: s.default_persons, + pref_veloce: s.pref_veloce, + pref_pocafame: s.pref_pocafame, + pref_scadenze: s.pref_scadenze, + pref_healthy: s.pref_healthy, + pref_comfort: s.pref_comfort, + pref_zerowaste: s.pref_zerowaste, + dietary: s.dietary, + appliances: s.appliances, + spesa_provider: s.spesa_provider, + spesa_ai_prompt: s.spesa_ai_prompt + }; + api('app_settings_save', {}, 'POST', { settings: { user_prefs: shared } }).catch(() => {}); +}, 1000); + +function debounce(fn, ms) { + let t; return function(...args) { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; +} + +async function syncSettingsFromDB() { + try { + const res = await api('app_settings_get'); + if (res.success && res.settings && res.settings.user_prefs) { + const db = res.settings.user_prefs; + const s = getSettings(); + // Merge DB settings into local (DB wins for shared prefs) + for (const key of ['default_persons','pref_veloce','pref_pocafame','pref_scadenze', + 'pref_healthy','pref_comfort','pref_zerowaste','dietary','appliances', + 'spesa_provider','spesa_ai_prompt']) { + if (db[key] !== undefined) s[key] = db[key]; + } + _settingsCache = s; + localStorage.setItem('dispensa_settings', JSON.stringify(s)); + } + } catch(e) { /* offline, use local */ } } async function loadSettingsUI() { @@ -773,6 +826,8 @@ function setReviewConfirmed(inventoryId) { const c = getReviewConfirmed(); c[inventoryId] = Date.now(); localStorage.setItem('review_confirmed', JSON.stringify(c)); + // Also persist to shared DB + api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {}); } async function loadReviewItems() { @@ -4027,24 +4082,32 @@ const MEAL_LABELS = { 'cena': '🌙 Cena' }; -// ===== RECIPE ARCHIVE ===== -function getRecipeArchive() { +// ===== RECIPE ARCHIVE (DB-backed) ===== +let _recipeArchiveCache = null; + +async function getRecipeArchive() { + if (_recipeArchiveCache !== null) return _recipeArchiveCache; try { - return JSON.parse(localStorage.getItem('dispensa_recipe_archive') || '[]'); - } catch { return []; } + const res = await api('recipes_list'); + if (res.success) { + _recipeArchiveCache = res.recipes || []; + return _recipeArchiveCache; + } + } catch(e) { console.warn('Failed to load recipes from DB:', e); } + return []; } -function saveRecipeToArchive(recipe) { - const archive = getRecipeArchive(); +async function saveRecipeToArchive(recipe) { const today = new Date().toISOString().slice(0, 10); - archive.unshift({ date: today, meal: recipe.meal, recipe, savedAt: Date.now() }); - // Keep max 60 recipes - if (archive.length > 60) archive.length = 60; - localStorage.setItem('dispensa_recipe_archive', JSON.stringify(archive)); + try { + await api('recipes_save', {}, 'POST', { date: today, meal: recipe.meal, recipe }); + // Invalidate cache so next load fetches fresh data + _recipeArchiveCache = null; + } catch(e) { console.error('Failed to save recipe:', e); } } -function getTodayRecipeTitles() { - const archive = getRecipeArchive(); +async function getTodayRecipeTitles() { + const archive = await getRecipeArchive(); const today = new Date().toISOString().slice(0, 10); return archive .filter(e => e.date === today && e.recipe && e.recipe.title) @@ -4053,10 +4116,10 @@ function getTodayRecipeTitles() { let _recipeArchiveEntries = []; -function loadRecipeArchive() { +async function loadRecipeArchive() { const container = document.getElementById('recipe-archive'); if (!container) return; - const archive = getRecipeArchive(); + const archive = await getRecipeArchive(); _recipeArchiveEntries = archive; if (archive.length === 0) { @@ -4119,6 +4182,8 @@ function viewArchivedRecipe(idx) { document.getElementById('recipe-result').style.display = ''; } +let _cachedRecipe = null; + function openRecipeDialog() { const meal = getMealType(); const settings = getSettings(); @@ -4126,16 +4191,13 @@ function openRecipeDialog() { document.getElementById('recipe-overlay').style.display = 'flex'; // Check for cached recipe matching current meal type - try { - const cached = JSON.parse(localStorage.getItem('cachedRecipe') || 'null'); - if (cached && cached.meal === meal && cached.recipe) { - document.getElementById('recipe-ask').style.display = 'none'; - document.getElementById('recipe-loading').style.display = 'none'; - renderRecipe(cached.recipe); - document.getElementById('recipe-result').style.display = ''; - return; - } - } catch (e) { /* ignore parse errors */ } + if (_cachedRecipe && _cachedRecipe.meal === meal && _cachedRecipe.recipe) { + document.getElementById('recipe-ask').style.display = 'none'; + document.getElementById('recipe-loading').style.display = 'none'; + renderRecipe(_cachedRecipe.recipe); + document.getElementById('recipe-result').style.display = ''; + return; + } // Pre-fill persons from settings document.getElementById('recipe-persons').value = settings.default_persons || 1; @@ -4198,13 +4260,9 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn) { btn.classList.add('btn-used'); // Persist used state in cached recipe - try { - const cached = JSON.parse(localStorage.getItem('cachedRecipe') || 'null'); - if (cached && cached.recipe && cached.recipe.ingredients && cached.recipe.ingredients[idx]) { - cached.recipe.ingredients[idx].used = true; - localStorage.setItem('cachedRecipe', JSON.stringify(cached)); - } - } catch (e) { /* ignore */ } + if (_cachedRecipe && _cachedRecipe.recipe && _cachedRecipe.recipe.ingredients && _cachedRecipe.recipe.ingredients[idx]) { + _cachedRecipe.recipe.ingredients[idx].used = true; + } showToast('📦 Ingrediente scalato dalla dispensa!', 'success'); if (result.added_to_bring) { @@ -4294,7 +4352,7 @@ function renderRecipe(r) { } function regenerateRecipe() { - localStorage.removeItem('cachedRecipe'); + _cachedRecipe = null; document.getElementById('recipe-result').style.display = 'none'; document.getElementById('recipe-loading').style.display = 'none'; const meal = getMealType(); @@ -4334,7 +4392,7 @@ async function generateRecipe() { options, appliances: settings.appliances || [], dietary_restrictions: settings.dietary_restrictions || '', - today_recipes: getTodayRecipeTitles() + today_recipes: await getTodayRecipeTitles() }); if (!result.success) { @@ -4354,8 +4412,8 @@ async function generateRecipe() { // Save to archive saveRecipeToArchive(r); - // Cache the recipe for this meal type - localStorage.setItem('cachedRecipe', JSON.stringify({ meal, recipe: r })); + // Cache the recipe for this meal type (in-memory only) + _cachedRecipe = { meal, recipe: r }; document.getElementById('recipe-loading').style.display = 'none'; document.getElementById('recipe-result').style.display = ''; @@ -4373,14 +4431,13 @@ let chatHistory = []; let chatInventoryContext = null; function initChat() { - // Load chat history from localStorage - const saved = localStorage.getItem('gemini_chat_history'); - if (saved) { - try { - chatHistory = JSON.parse(saved); + // Load chat history from DB + api('chat_list').then(res => { + if (res.success && res.messages && res.messages.length > 0) { + chatHistory = res.messages.map(m => ({ role: m.role, text: m.text })); renderChatHistory(); - } catch(e) { chatHistory = []; } - } + } + }).catch(() => {}); // Pre-load inventory context loadChatContext(); // Focus input @@ -4515,7 +4572,7 @@ function scrollChatBottom() { function clearChat() { chatHistory = []; - localStorage.removeItem('gemini_chat_history'); + api('chat_clear', {}, 'POST').catch(() => {}); const container = document.getElementById('chat-messages'); container.innerHTML = `
@@ -4536,11 +4593,14 @@ function clearChat() { function saveChatHistory() { // Keep last 50 messages max if (chatHistory.length > 50) chatHistory = chatHistory.slice(-50); - localStorage.setItem('gemini_chat_history', JSON.stringify(chatHistory)); + // Save last 2 messages (the newest pair) to DB + const newMsgs = chatHistory.slice(-2); + api('chat_save', {}, 'POST', { messages: newMsgs }).catch(() => {}); } // ===== INITIALIZATION ===== document.addEventListener('DOMContentLoaded', () => { + syncSettingsFromDB(); showPage('dashboard'); }); diff --git a/data/dispensa.db b/data/dispensa.db index e8de3f4..43274da 100644 Binary files a/data/dispensa.db and b/data/dispensa.db differ diff --git a/index.html b/index.html index d227587..47c2b0c 100644 --- a/index.html +++ b/index.html @@ -858,6 +858,6 @@
- +