Move recipes, chat, settings to shared DB — sync across devices, 1 recipe per meal per day

This commit is contained in:
dadaloop82
2026-03-12 18:06:50 +00:00
parent a7dabbce87
commit f2b090f107
5 changed files with 295 additions and 60 deletions
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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');
});
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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>