Move recipes, chat, settings to shared DB — sync across devices, 1 recipe per meal per day
This commit is contained in:
@@ -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
|
||||
);
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
+135
-2
@@ -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]);
|
||||
}
|
||||
|
||||
+117
-57
@@ -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 = `
|
||||
<div class="chat-welcome">
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -858,6 +858,6 @@
|
||||
<div class="modal-content" id="modal-content" onclick="event.stopPropagation()"></div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260312x"></script>
|
||||
<script src="assets/js/app.js?v=20260312y"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user