Merge develop: feat #68 scan history + server-side data centralisation

This commit is contained in:
dadaloop82
2026-05-17 08:03:39 +00:00
2 changed files with 153 additions and 99 deletions
+7
View File
@@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
## [1.7.16] - 2026-05-17
### Added
- **Barcode scan history** — Last 20 scanned products are stored server-side (SQLite `app_settings`) and shown as chips in the scan page (`#scan-recents-chips`). Tapping a chip selects the product directly — no need to scan again. Resolves [#68](https://github.com/dadaloop82/EverShelf/issues/68).
- **Full server-side user-data centralisation** — All user preferences previously siloed in `localStorage` per-device are now synced to the server via `app_settings_save` and loaded back at startup via `app_settings_get`. Affected data: shopping tags, pinned Bring! items, location preferences (use/move), auto-added Bring! entries, Bring! purchased blocklist, no-expiry dismissed products. Data is now shared across all devices (desktop, phone, kiosk, Android app).
- **One-time localStorage migration** — On first load, any data found in the old localStorage keys (`shopping_tags`, `_userPinnedBring`, `_prefUseLoc`, `_prefMoveLoc`, `_autoAddedBring`, `_bringPurchasedBlocklist`, `_noExpiryDismissed`, `evershelf_scan_recents`) is automatically migrated to the server and the local keys are removed.
## [1.7.15] - 2026-05-16
### Added
+146 -99
View File
@@ -1833,24 +1833,20 @@ function switchScanTab(tab) {
}
}
// ===== SCAN RECENTS (localStorage) =====
const _SCAN_RECENTS_KEY = 'evershelf_scan_recents';
const _SCAN_RECENTS_MAX = 6;
function _getScanRecents() {
try { return JSON.parse(localStorage.getItem(_SCAN_RECENTS_KEY) || '[]'); } catch(_) { return []; }
}
// ===== SCAN HISTORY (server-synced via app_settings key "scan_history") =====
const _SCAN_HISTORY_MAX = 20;
function addToScanRecents(product) {
if (!product || !product.id) return;
let list = _getScanRecents().filter(r => r.id !== product.id);
list.unshift({ id: product.id, name: product.name, brand: product.brand || '', category: product.category || '' });
if (list.length > _SCAN_RECENTS_MAX) list = list.slice(0, _SCAN_RECENTS_MAX);
try { localStorage.setItem(_SCAN_RECENTS_KEY, JSON.stringify(list)); } catch(_) {}
let list = (_scanHistoryCache || []).filter(r => r.id !== product.id);
list.unshift({ id: product.id, barcode: product.barcode || '', name: product.name, brand: product.brand || '', category: product.category || '', ts: Date.now() });
if (list.length > _SCAN_HISTORY_MAX) list = list.slice(0, _SCAN_HISTORY_MAX);
_scanHistoryCache = list;
_saveToServer('scan_history', list);
}
function updateScanRecents() {
const list = _getScanRecents();
const list = (_scanHistoryCache || []).slice(0, 6);
const wrap = document.getElementById('scan-recents');
const chips = document.getElementById('scan-recents-chips');
if (!wrap || !chips) return;
@@ -2038,27 +2034,64 @@ async function syncSettingsFromDB() {
// Primary: load from server .env (only when not already done via _applySyncedSettings)
const serverSettings = await api('get_settings');
_applySyncedSettings(serverSettings);
// Also load review_confirmed, meal_plan, tts_voice from DB (cross-device shared)
// Load all server-persisted user data from SQLite app_settings
const res = await api('app_settings_get');
if (res.success && res.settings) {
if (res.settings.review_confirmed) {
_reviewConfirmedCache = res.settings.review_confirmed;
}
const srv = res.settings;
if (srv.review_confirmed) _reviewConfirmedCache = srv.review_confirmed;
// meal_plan is stored in SQLite app_settings so all devices stay in sync
if (res.settings.meal_plan) {
if (srv.meal_plan) {
const s = getSettings();
s.meal_plan = res.settings.meal_plan;
s.meal_plan = srv.meal_plan;
_settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s));
if (document.getElementById('meal-plan-grid')) renderMealPlanEditor();
}
// tts_voice preference (best-effort cross-device — falls back if voice unavailable)
if (res.settings.tts_voice) {
if (srv.tts_voice) {
const s = getSettings();
if (!s.tts_voice) { s.tts_voice = res.settings.tts_voice; _settingsCache = s; localStorage.setItem('evershelf_settings', JSON.stringify(s)); }
if (!s.tts_voice) { s.tts_voice = srv.tts_voice; _settingsCache = s; localStorage.setItem('evershelf_settings', JSON.stringify(s)); }
}
// ── User data previously stored in localStorage, now server-synced ──
if (srv.scan_history) _scanHistoryCache = srv.scan_history;
if (srv.shopping_tags) _shoppingTagsCache = srv.shopping_tags;
if (srv.pinned_bring) _pinnedBringCache = srv.pinned_bring;
if (srv.pref_use_loc) _prefUseLocCache = srv.pref_use_loc;
if (srv.pref_move_loc) _prefMoveLocCache = srv.pref_move_loc;
if (srv.auto_added_bring) _autoAddedBringCache = srv.auto_added_bring;
if (srv.bring_blocklist) _bringBlocklistCache = srv.bring_blocklist;
if (srv.no_expiry_dismissed) _noExpiryDismissedCache = srv.no_expiry_dismissed;
// ── One-time migration: if server has nothing yet, seed from old localStorage ──
if (!srv.shopping_tags) {
try { const v = localStorage.getItem('shopping_tags'); if (v) { _shoppingTagsCache = JSON.parse(v); _saveToServer('shopping_tags', _shoppingTagsCache); localStorage.removeItem('shopping_tags'); } } catch(_) {}
}
if (!srv.pinned_bring) {
try { const v = localStorage.getItem('_userPinnedBring'); if (v) { _pinnedBringCache = JSON.parse(v); _saveToServer('pinned_bring', _pinnedBringCache); localStorage.removeItem('_userPinnedBring'); } } catch(_) {}
}
if (!srv.pref_use_loc) {
try { const v = localStorage.getItem('_prefUseLoc'); if (v) { _prefUseLocCache = JSON.parse(v); _saveToServer('pref_use_loc', _prefUseLocCache); localStorage.removeItem('_prefUseLoc'); } } catch(_) {}
}
if (!srv.pref_move_loc) {
try { const v = localStorage.getItem('_prefMoveLoc'); if (v) { _prefMoveLocCache = JSON.parse(v); _saveToServer('pref_move_loc', _prefMoveLocCache); localStorage.removeItem('_prefMoveLoc'); } } catch(_) {}
}
if (!srv.auto_added_bring) {
try { const v = localStorage.getItem('_autoAddedBring'); if (v) { _autoAddedBringCache = JSON.parse(v); _saveToServer('auto_added_bring', _autoAddedBringCache); localStorage.removeItem('_autoAddedBring'); } } catch(_) {}
}
if (!srv.bring_blocklist) {
try { const v = localStorage.getItem('_bringPurchasedBlocklist'); if (v) { _bringBlocklistCache = JSON.parse(v); _saveToServer('bring_blocklist', _bringBlocklistCache); localStorage.removeItem('_bringPurchasedBlocklist'); } } catch(_) {}
}
if (!srv.no_expiry_dismissed) {
try { const v = localStorage.getItem('_noExpiryDismissed'); if (v) { _noExpiryDismissedCache = JSON.parse(v); _saveToServer('no_expiry_dismissed', _noExpiryDismissedCache); localStorage.removeItem('_noExpiryDismissed'); } } catch(_) {}
}
if (!srv.scan_history) {
try { const v = localStorage.getItem('evershelf_scan_recents'); if (v) { _scanHistoryCache = JSON.parse(v); _saveToServer('scan_history', _scanHistoryCache); localStorage.removeItem('evershelf_scan_recents'); } } catch(_) {}
}
}
} catch(e) { /* offline, use local */ }
} catch(e) { /* offline — in-memory caches stay at their defaults */ }
}
/**
@@ -3886,6 +3919,20 @@ function getReviewConfirmed() {
return _reviewConfirmedCache || {};
}
let _reviewConfirmedCache = {};
// ===== SERVER-SYNCED APP DATA CACHES =====
// Loaded at startup from app_settings (SQLite). Reads are synchronous (from cache).
// Writes update cache + fire-and-forget to server via app_settings_save.
let _shoppingTagsCache = {};
let _pinnedBringCache = {};
let _prefUseLocCache = {};
let _prefMoveLocCache = {};
let _autoAddedBringCache = {};
let _bringBlocklistCache = {};
let _noExpiryDismissedCache = {};
let _scanHistoryCache = [];
function _saveToServer(key, value) {
api('app_settings_save', {}, 'POST', { settings: { [key]: value } }).catch(() => {});
}
function setReviewConfirmed(inventoryId) {
const c = getReviewConfirmed();
@@ -3896,13 +3943,14 @@ function setReviewConfirmed(inventoryId) {
/** Return map of product IDs the user has marked as "no expiry needed". */
function _getNoExpiryDismissed() {
try { return JSON.parse(localStorage.getItem('_noExpiryDismissed') || '{}'); } catch { return {}; }
return _noExpiryDismissedCache || {};
}
/** Permanently mark a product as "no expiry needed" for this browser. */
function _dismissNoExpiry(productId) {
const m = _getNoExpiryDismissed();
const m = Object.assign({}, _noExpiryDismissedCache || {});
m[String(productId)] = Date.now();
localStorage.setItem('_noExpiryDismissed', JSON.stringify(m));
_noExpiryDismissedCache = m;
_saveToServer('no_expiry_dismissed', m);
}
// === ALERT BANNER SYSTEM (replaces old review table) ===
@@ -8001,33 +8049,28 @@ function selectUseLocation(btn, loc) {
// ── PREFERRED USE LOCATION ───────────────────────────────────────────────
// After 3+ consistent choices from the same location for a product,
// auto-selects it and hides the location picker (user can still tap "cambia").
const _PREF_LOC_KEY = '_prefUseLoc';
const _PREF_LOC_NEEDED = 2; // choices needed to confirm a preference
// ── PREFERRED MOVE-AFTER-USE LOCATION ────────────────────────────────────
// Tracks where the user puts the remainder after using a product.
// After _PREF_MOVE_NEEDED consistent choices, the modal is skipped entirely.
const _PREF_MOVE_KEY = '_prefMoveLoc';
const _PREF_MOVE_NEEDED = 2;
let _pendingMoveCtx = null; // { productId, fromLoc, openedId } — set before showing modal
function _getMoveLocHistory(productId, fromLoc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
return all[`${productId}|${fromLoc}`] || [];
} catch { return []; }
const all = _prefMoveLocCache || {};
return all[`${productId}|${fromLoc}`] || [];
}
function _recordMoveLocChoice(productId, fromLoc, toLoc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_MOVE_KEY) || '{}');
const key = `${productId}|${fromLoc}`;
const hist = all[key] || [];
hist.push(toLoc);
if (hist.length > 8) hist.splice(0, hist.length - 8);
all[key] = hist;
localStorage.setItem(_PREF_MOVE_KEY, JSON.stringify(all));
} catch { }
const all = Object.assign({}, _prefMoveLocCache || {});
const key = `${productId}|${fromLoc}`;
const hist = (all[key] || []).slice();
hist.push(toLoc);
if (hist.length > 8) hist.splice(0, hist.length - 8);
all[key] = hist;
_prefMoveLocCache = all;
_saveToServer('pref_move_loc', all);
}
function _getPreferredMoveLoc(productId, fromLoc) {
@@ -8041,22 +8084,19 @@ function _getPreferredMoveLoc(productId, fromLoc) {
}
function _getPrefLocHistory(productId) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
return all[String(productId)] || [];
} catch { return []; }
const all = _prefUseLocCache || {};
return all[String(productId)] || [];
}
function _recordUseLocationChoice(productId, loc) {
try {
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
const key = String(productId);
const hist = all[key] || [];
hist.push(loc);
if (hist.length > 8) hist.splice(0, hist.length - 8); // keep last 8
all[key] = hist;
localStorage.setItem(_PREF_LOC_KEY, JSON.stringify(all));
} catch { }
const all = Object.assign({}, _prefUseLocCache || {});
const key = String(productId);
const hist = (all[key] || []).slice();
hist.push(loc);
if (hist.length > 8) hist.splice(0, hist.length - 8);
all[key] = hist;
_prefUseLocCache = all;
_saveToServer('pref_use_loc', all);
}
function _getPreferredUseLocation(productId) {
@@ -8332,9 +8372,10 @@ async function addLowStockToBring() {
const data = await api('bring_add', {}, 'POST', payload);
if (data.success && data.added > 0) {
// Pin as user-added so cleanup never auto-removes it
const pinned = JSON.parse(localStorage.getItem('_userPinnedBring') || '{}');
const pinned = Object.assign({}, _pinnedBringCache || {});
pinned[bringName.toLowerCase()] = Date.now();
localStorage.setItem('_userPinnedBring', JSON.stringify(pinned));
_pinnedBringCache = pinned;
_saveToServer('pinned_bring', pinned);
showToast(t('shopping.added_to_bring').replace('{n}', data.added), 'success');
} else if (data.success && data.skipped > 0) {
showToast(t('shopping.already_in_list_short'), 'info');
@@ -9309,27 +9350,26 @@ function updateShoppingTabCounts() {
document.getElementById('shopping-tabs')?.style.setProperty('display', 'flex');
}
// ===== LOCAL SHOPPING TAGS =====
// ===== LOCAL SHOPPING TAGS (server-synced) =====
function getShoppingTags(itemName) {
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
return tags[itemName.toLowerCase()] || [];
} catch { return []; }
const tags = _shoppingTagsCache || {};
return tags[itemName.toLowerCase()] || [];
}
function toggleShoppingTag(itemIdx, tag) {
const item = shoppingItems[itemIdx];
if (!item) return;
const key = item.name.toLowerCase();
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
const existing = tags[key] || [];
const key = item.name.toLowerCase();
const tags = Object.assign({}, _shoppingTagsCache || {});
const existing = (tags[key] || []).slice();
const pos = existing.indexOf(tag);
if (pos >= 0) existing.splice(pos, 1);
else existing.push(tag);
if (existing.length) tags[key] = existing;
else delete tags[key];
localStorage.setItem('shopping_tags', JSON.stringify(tags));
_shoppingTagsCache = tags;
_saveToServer('shopping_tags', tags);
// Sync urgente/presto tag to Bring specification so it's visible in the Bring app
if (tag === 'urgente' && shoppingListUUID) {
@@ -9391,54 +9431,57 @@ function _urgencyToSpec(urgency, brand) {
* function only ever removes those, never manually-added ones.
*/
function _getAutoAddedBring() {
try {
const raw = localStorage.getItem('_autoAddedBring');
const map = raw ? JSON.parse(raw) : {};
const now = Date.now();
let changed = false;
for (const k of Object.keys(map)) {
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
}
if (changed) localStorage.setItem('_autoAddedBring', JSON.stringify(map));
return map;
} catch(e) { return {}; }
const map = Object.assign({}, _autoAddedBringCache || {});
const now = Date.now();
let changed = false;
for (const k of Object.keys(map)) {
if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; }
}
if (changed) {
_autoAddedBringCache = map;
_saveToServer('auto_added_bring', map);
}
return map;
}
function _markAutoAddedBring(names) {
const map = _getAutoAddedBring();
const now = Date.now();
for (const n of names) map[n.toLowerCase()] = now;
localStorage.setItem('_autoAddedBring', JSON.stringify(map));
_autoAddedBringCache = map;
_saveToServer('auto_added_bring', map);
}
function _unmarkAutoAddedBring(names) {
const map = _getAutoAddedBring();
for (const n of names) delete map[n.toLowerCase()];
localStorage.setItem('_autoAddedBring', JSON.stringify(map));
_autoAddedBringCache = map;
_saveToServer('auto_added_bring', map);
}
// ===== BRING! PURCHASED BLOCKLIST =====
// ===== BRING! PURCHASED BLOCKLIST (server-synced) =====
// When an item disappears from Bring (user bought it), we block auto-re-add for 4h.
const _BRING_PURCHASED_TTL = 4 * 60 * 60 * 1000; // 4 hours
function _getBringPurchasedBlocklist() {
try {
const raw = localStorage.getItem('_bringPurchasedBlocklist');
const map = raw ? JSON.parse(raw) : {};
const now = Date.now();
// Prune expired entries
let changed = false;
for (const key of Object.keys(map)) {
if (now - map[key] > _BRING_PURCHASED_TTL) { delete map[key]; changed = true; }
}
if (changed) localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
return map;
} catch(e) { return {}; }
const map = Object.assign({}, _bringBlocklistCache || {});
const now = Date.now();
// Prune expired entries
let changed = false;
for (const key of Object.keys(map)) {
if (now - map[key] > _BRING_PURCHASED_TTL) { delete map[key]; changed = true; }
}
if (changed) {
_bringBlocklistCache = map;
_saveToServer('bring_blocklist', map);
}
return map;
}
function _markBringPurchased(names) {
const map = _getBringPurchasedBlocklist();
const now = Date.now();
for (const n of names) map[n.toLowerCase()] = now;
localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
_bringBlocklistCache = map;
_saveToServer('bring_blocklist', map);
}
function _isBringPurchased(name, urgency) {
@@ -9501,10 +9544,10 @@ async function forceSyncBring() {
if (btn) { btn.disabled = true; btn.textContent = `${t('shopping.syncing')}`; }
// Clear auto-add/cleanup guards so the next run is unconditional.
// Do NOT clear _userPinnedBring — items the user manually added must stay protected.
localStorage.removeItem('_bringPurchasedBlocklist');
_bringBlocklistCache = {}; _saveToServer('bring_blocklist', {});
localStorage.removeItem('_autoAddedCriticalTs');
localStorage.removeItem('_bringCleanupTs');
localStorage.removeItem('_autoAddedBring');
_autoAddedBringCache = {}; _saveToServer('auto_added_bring', {});
logOperation('force_sync_bring', {});
// Reload everything from scratch
await loadShoppingList();
@@ -9754,10 +9797,10 @@ async function fetchAllPrices(forceRefresh = false) {
if (btn) { btn.disabled = true; btn.textContent = `${t('shopping.syncing')}`; }
// Clear auto-add/cleanup guards so the next run is unconditional.
// Do NOT clear _userPinnedBring — items the user manually added must stay protected.
localStorage.removeItem('_bringPurchasedBlocklist');
_bringBlocklistCache = {}; _saveToServer('bring_blocklist', {});
localStorage.removeItem('_autoAddedCriticalTs');
localStorage.removeItem('_bringCleanupTs');
localStorage.removeItem('_autoAddedBring');
_autoAddedBringCache = {}; _saveToServer('auto_added_bring', {});
logOperation('force_sync_bring', {});
// Reload everything from scratch
await loadShoppingList();
@@ -10313,10 +10356,11 @@ async function addSmartToBring() {
showToast(msg, result.added > 0 ? 'success' : 'info');
// Mark all manually-added items as user-pinned so cleanupObsoleteBringItems never removes them
if (result.added > 0) {
const pinned = JSON.parse(localStorage.getItem('_userPinnedBring') || '{}');
const pinned = Object.assign({}, _pinnedBringCache || {});
const now = Date.now();
for (const it of itemsToAdd) pinned[it.name.toLowerCase()] = now;
localStorage.setItem('_userPinnedBring', JSON.stringify(pinned));
_pinnedBringCache = pinned;
_saveToServer('pinned_bring', pinned);
}
// Reload to refresh badges
loadShoppingList();
@@ -10369,12 +10413,12 @@ async function loadShoppingCount() {
*/
function _syncTagsFromBringSpec() {
try {
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
const tags = Object.assign({}, _shoppingTagsCache || {});
let changed = false;
for (const item of shoppingItems) {
const key = item.name.toLowerCase();
const spec = (item.specification || '').toLowerCase();
const existing = tags[key] || [];
const existing = (tags[key] || []).slice();
const hasUrgente = existing.includes('urgente');
const smartMatch = _matchBringToSmart(item.name, smartShoppingItems);
const smartIsCritical = smartMatch && (smartMatch.urgency === 'critical' || smartMatch.urgency === 'high');
@@ -10389,7 +10433,10 @@ function _syncTagsFromBringSpec() {
changed = true;
}
}
if (changed) localStorage.setItem('shopping_tags', JSON.stringify(tags));
if (changed) {
_shoppingTagsCache = tags;
_saveToServer('shopping_tags', tags);
}
} catch (e) { /* ignore */ }
}