chore: auto-merge develop → main

Triggered by: eddb622 feat(offline): full offline mode — cache sync, write queue, startup recovery
This commit is contained in:
github-actions[bot]
2026-05-25 09:27:09 +00:00
11 changed files with 7535 additions and 6834 deletions
+12 -7
View File
@@ -11,15 +11,20 @@ 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.25] - 2026-05-23
### Fixed
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately after removing an item, so the purchased blocklist is populated before the next background refresh. `autoAddCriticalItems` now also respects the blocklist for depleted items (quantity = 0), preventing re-addition of just-bought products.
- **Barcode lookup false "not found"** — Added `_offFetchProduct()` PHP helper that tries up to three barcode candidates (given code, UPC-A → EAN-13 by prepending `0`, EAN-13 → UPC-A by stripping leading `0`) against two Open Food Facts locales (`it` and default), with one automatic retry on network error. UPCItemDB fallback also iterates the same candidates. Reduces false negatives on 12-digit US barcodes and region-specific EAN codes.
- **Partial throw from expired-items banner** — The "Butta" button in the dashboard banner previously discarded the entire inventory row with no confirmation. It now opens the existing throw modal (location picker + quantity input + "Butta tutto" option), consistent with the throw flow available from the scan → action page.
## [1.7.25] - 2026-05-25
### Added
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family (matching first name token or shopping name, different product ID) already at home, so you avoid double-buying.
- **Home Assistant integration** — Full bidirectional HA support: inventory sensor (`sensor.evershelf_*`) exposes item counts, expiring items, shopping total, opened items and next-expiry info. Webhooks fire on inventory changes (add/use/shopping). Daily cron alert notifies via HA for items expiring within the configured threshold. TTS announces cooking steps through HA Media Player. New Settings tab 🏠 with connection test, TTS preset (Piper, Google, Nabu Casa), webhook config, and YAML snippet for `configuration.yaml`. Resolves [#111](https://github.com/dadaloop82/EverShelf/issues/111).
- **Offline mode** — Full offline-first support. Full-screen overlay on network loss; "Continue offline" button after 3 s, auto-enter after 8 s. Inventory and settings are synced to `localStorage` at startup and cached on every successful API call. Writes (add/use/update/delete) are queued and synced on reconnect with optimistic UI updates. Pending operations survive page refresh and are re-synced automatically at next startup. AI/network-dependent sections (anti-waste chart, nutrition analysis, recipe generator, price fetching, Gemini chat) are hidden in offline mode. `remoteLog` and `reportError` are buffered offline and flushed on restore. Broken external images replaced with a grey placeholder.
- **Offline-computed dashboard** — While offline, `inventory_summary` and `stats` (expiring/expired/opened) are derived client-side from the local cache so all dashboard stat cards and expiry alerts show accurate data.
### Fixed
- **Offline banner flood** — Opened items in the offline `stats` response lacked `is_edible`; `!undefined` evaluated to `true`, causing every opened item to be shown as "not edible" in the dashboard banner. Field is now set to `true` (client-side shelf-life check already handles genuinely expired items).
- **Version update badge showing older versions** — `_checkWebappUpdate` used `latestTag !== _loadedVersion` (inequality only), so running a newer dev build triggered an "update available" badge for an older GitHub release. Now uses `_semverGt(latest, current)` so only genuinely newer releases trigger the badge.
- **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately, and `autoAddCriticalItems` respects the blocklist for depleted items.
- **Barcode lookup false "not found"** — New `_offFetchProduct()` tries three barcode candidates (given, UPC-A↔EAN-13 conversion) across two Open Food Facts locales with auto-retry.
- **Partial throw from expired-items banner** — "Butta" now opens the throw modal (qty + location) instead of silently deleting the entire inventory row.
- **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family already at home.
## [1.7.24] - 2026-05-21
+10 -1
View File
@@ -137,7 +137,16 @@ Connect your pantry to your smart home in minutes — no YAML, no manual sensor
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
- **Installable** — Add to home screen for a native app experience
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
### 📶 Offline Mode
- **Automatic detection** — Full-screen overlay appears immediately on network loss; shows a "Continue offline" button after 3 s, and auto-enters offline mode after 8 s
- **Local inventory cache** — Inventory is synced to `localStorage` at every startup and on each successful API call; the offline view always reflects the last known state
- **Write queue** — Add, use, update and delete operations performed while offline are queued locally and synced to the server automatically on reconnect (including after a page refresh)
- **Optimistic UI** — Queued writes are applied immediately to the local cache so the interface stays responsive
- **Offline-computed stats** — Expiring and expired items are derived client-side from the cache; dashboard stat cards show real counts instead of zeros
- **AI/network sections hidden** — Anti-waste chart, nutrition analysis, recipe generator, price fetching, and Gemini chat are hidden in offline mode; the inventory, history, and manually-managed shopping list remain fully functional
- **Broken image fallback** — External product images (Open Food Facts, etc.) that fail to load are replaced with a neutral grey placeholder, keeping the layout intact
- **Startup recovery** — If the page is refreshed while operations are queued, they are detected and synced automatically on the next successful startup
- **Buffered error reporting** — `remoteLog` and `reportError` calls made while offline are stored locally and flushed to the server (and to GitHub issues) when the connection is restored
### ⚖️ Smart Scale Integration (Add-on)
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
+139 -1
View File
@@ -596,13 +596,37 @@ body {
}
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
/* Pulsing dot shown in the banner while the offline cache is being read */
.offline-banner-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: #f87171;
margin-right: 6px;
vertical-align: middle;
animation: offline-dot-pulse 1.1s ease-in-out infinite;
}
@keyframes offline-dot-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.15); }
}
/* When server is offline, block interactions with the main content */
body.server-offline .app-content {
body.server-offline:not(.offline-mode) .app-content {
opacity: 0.4;
pointer-events: none;
user-select: none;
transition: opacity 0.3s;
}
/* In offline-mode the app is usable; just a subtle left-border indicator */
body.offline-mode .app-content {
border-left: 3px solid rgba(239, 68, 68, 0.45);
}
/* Hide the "Retry" button in the banner when in offline mode — use the Continue button instead */
body.offline-mode .offline-banner-retry {
display: none;
}
body.server-offline .bottom-nav {
opacity: 0.4;
pointer-events: none;
@@ -7655,3 +7679,117 @@ body.cooking-mode-active .app-header {
/* ── Appliance remove active ── */
[data-theme="dark"] .appliance-item .appliance-remove:active { background: #2a0808; }
/* ===== NETWORK ERROR OVERLAY ===== */
#network-error-overlay {
position: fixed;
inset: 0;
background: rgba(6, 8, 20, 0.97);
z-index: 300000; /* highest: above screensaver(10000), cooking(99999), preloader(200000) */
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
}
#network-error-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.net-error-body {
text-align: center;
padding: 2.5rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
}
.net-error-icon {
font-size: 5.5rem;
line-height: 1;
margin-bottom: 1.75rem;
animation: net-pulse 2.2s ease-in-out infinite;
display: block;
filter: drop-shadow(0 0 32px rgba(248, 113, 113, 0.35));
}
#network-error-overlay.restored .net-error-icon {
animation: none;
filter: drop-shadow(0 0 32px rgba(74, 222, 128, 0.45));
}
#network-error-overlay.checking .net-error-icon {
animation: net-spin 1.2s linear infinite;
}
@keyframes net-pulse {
0%, 100% { opacity: 0.45; transform: scale(0.92); }
50% { opacity: 1; transform: scale(1.06); }
}
@keyframes net-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.net-error-title {
font-size: 2.5rem;
font-weight: 700;
color: #f87171;
margin-bottom: 0.75rem;
letter-spacing: -0.02em;
transition: color 0.4s;
}
#network-error-overlay.restored .net-error-title {
color: #4ade80;
}
.net-error-subtitle {
font-size: 1.1rem;
color: #94a3b8;
max-width: 420px;
line-height: 1.6;
margin: 0 auto;
}
.net-error-status {
margin-top: 1.5rem;
font-size: 0.88rem;
color: #475569;
min-height: 1.3em;
letter-spacing: 0.01em;
}
/* "Continue in offline mode" button — appears after 3 s */
.net-error-continue-btn {
margin-top: 2.2rem;
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.22);
color: #94a3b8;
border-radius: 10px;
padding: 0.7rem 1.6rem;
font-size: 0.92rem;
cursor: pointer;
transition: background 0.2s, color 0.2s, transform 0.3s, opacity 0.3s;
opacity: 0;
transform: translateY(8px);
}
.net-error-continue-btn.visible {
opacity: 1;
transform: translateY(0);
}
.net-error-continue-btn:hover {
background: rgba(255,255,255,0.16);
color: #e2e8f0;
}
/* ─── Offline mode: hide AI and network-dependent UI ────────────────────────
Sections that require a live server response or AI are hidden so the user
isn't confronted with empty/broken widgets while offline. */
body.offline-mode #waste-chart-section,
body.offline-mode #nutrition-section,
body.offline-mode #quick-recipe-bar,
body.offline-mode .header-gemini-btn,
body.offline-mode #btn-suggest,
body.offline-mode #btn-fetch-prices,
body.offline-mode .recipe-generate-btn {
display: none !important;
}
/* Smart-shopping AI section: show as disabled rather than disappearing entirely */
body.offline-mode #smart-shopping {
opacity: 0.45;
pointer-events: none;
filter: grayscale(0.6);
}
+495 -17
View File
@@ -13,6 +13,8 @@
// 2. reportError() — immediate single POST → report_error endpoint → GitHub Issue
const _remoteLogBuffer = [];
const _OFFLINE_LOGS_KEY = '_evershelf_offline_logs'; // buffered log msgs while offline
const _OFFLINE_ERRORS_KEY = '_evershelf_offline_errors'; // buffered error reports while offline
let _remoteLogTimer = null;
const _origConsoleError = console.error.bind(console);
const _origConsoleWarn = console.warn.bind(console);
@@ -33,11 +35,25 @@ function flushRemoteLog() {
_remoteLogTimer = null;
if (_remoteLogBuffer.length === 0) return;
const msgs = _remoteLogBuffer.splice(0);
// If offline, buffer for flush on reconnect instead of losing them
const isOfflineNow = (typeof _serverOffline !== 'undefined' && _serverOffline) ||
(typeof _networkDown !== 'undefined' && _networkDown) ||
(typeof _offlineMode !== 'undefined' && _offlineMode);
if (isOfflineNow) { _bufferOfflineLogs(msgs); return; }
fetch(`api/index.php?action=client_log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: msgs })
}).catch(() => {});
}).catch(() => { _bufferOfflineLogs(msgs); }); // store if request itself fails
}
function _bufferOfflineLogs(msgs) {
try {
const pending = JSON.parse(localStorage.getItem(_OFFLINE_LOGS_KEY) || '[]');
pending.push(...msgs);
if (pending.length > 500) pending.splice(0, pending.length - 500);
localStorage.setItem(_OFFLINE_LOGS_KEY, JSON.stringify(pending));
} catch(e) {}
}
// Override console.error and console.warn to also send remotely
@@ -65,21 +81,48 @@ function reportError(payload) {
version: document.querySelector('.header-version')?.textContent?.trim() || '',
url: location.href,
user_agent: navigator.userAgent,
ts: new Date().toISOString(),
}, payload);
// When offline, buffer for replay when reconnected (→ GitHub issue on restore)
const isOfflineNow = (typeof _serverOffline !== 'undefined' && _serverOffline) ||
(typeof _networkDown !== 'undefined' && _networkDown) ||
(typeof _offlineMode !== 'undefined' && _offlineMode);
if (isOfflineNow) { _bufferOfflineError(body); return; }
fetch('api/index.php?action=report_error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).catch(() => {}); // fire-and-forget; never throw from error handler
}).catch(() => { _bufferOfflineError(body); }); // store if request itself fails
// Note: the server will also skip issue creation if this version is not the latest.
}
function _bufferOfflineError(body) {
try {
const pending = JSON.parse(localStorage.getItem(_OFFLINE_ERRORS_KEY) || '[]');
pending.push(body);
if (pending.length > 50) pending.splice(0, pending.length - 50);
localStorage.setItem(_OFFLINE_ERRORS_KEY, JSON.stringify(pending));
} catch(e) {}
}
// ── Webapp update notification ───────────────────────────────────────────────
// Checks both the deployed webapp version and the latest GitHub release.
// Fires on tab focus and every 5 minutes.
const _loadedVersion = (document.querySelector('.header-version')?.textContent?.trim() || '').replace(/^v/, '');
// ── Broken image fallback ─────────────────────────────────────────────────────
// External product images (Open Food Facts, etc.) are unavailable when offline.
// Replace any broken <img> with a neutral grey placeholder so the layout stays intact.
document.addEventListener('error', (e) => {
if (e.target.tagName !== 'IMG' || e.target.dataset.offlineErr) return;
e.target.dataset.offlineErr = '1';
// 60x60 grey placeholder SVG with a '?' glyph
e.target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Crect width='60' height='60' rx='8' fill='%231e293b'/%3E%3Ctext x='30' y='38' text-anchor='middle' fill='%2364748b' font-size='24' font-family='sans-serif'%3E%3F%3C/text%3E%3C/svg%3E";
e.target.style.opacity = '0.45';
}, true);
// ── Gemini AI availability ────────────────────────────────────────────────────
// Set to true in _initApp / syncSettingsFromDB once server confirms key is set.
// All AI entry points call _requireGemini() before opening camera / API calls.
@@ -133,6 +176,17 @@ function _applyDemoModeUI() {
}
}
function _semverGt(a, b) {
// Returns true if version string a is strictly greater than b (e.g. "1.7.25" > "1.7.23")
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0, nb = pb[i] || 0;
if (na !== nb) return na > nb;
}
return false;
}
function _checkWebappUpdate() {
const STORAGE_KEY = '_evershelf_update_checked_at';
const SEEN_KEY = '_evershelf_update_seen_ts';
@@ -151,7 +205,7 @@ function _checkWebappUpdate() {
// ── Check 1: server has a newer version deployed since this page loaded ──
const serverVer = (data.webapp_version || '').replace(/^v/, '');
const deployChanged = serverVer && _loadedVersion && serverVer !== _loadedVersion;
const deployChanged = serverVer && _loadedVersion && _semverGt(serverVer, _loadedVersion);
// ── Check 2: a newer GitHub release not yet acknowledged ──
const publishedAt = data.published_at || '';
@@ -159,7 +213,7 @@ function _checkWebappUpdate() {
const latestTag = (data.latest_tag || '').replace(/^v/, '');
const releaseNewer = publishedAt && publishedAt !== seenTs &&
/^\d+\.\d+/.test(latestTag) &&
_loadedVersion && latestTag !== _loadedVersion;
_loadedVersion && _semverGt(latestTag, _loadedVersion);
if (!deployChanged && !releaseNewer) return;
@@ -3611,6 +3665,10 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
return { success: true, purchase: shoppingItems, listUUID: 'demo-list', _demo: true };
}
}
// In offline mode, serve from cache / queue writes
if (_offlineMode) {
return _handleOfflineApi(action, params, body);
}
let url = `${API_BASE}?action=${action}`;
if (method === 'GET') {
Object.entries(params).forEach(([k, v]) => {
@@ -3624,7 +3682,20 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
} else if (Object.keys(extraHeaders).length > 0) {
opts.headers = { ...extraHeaders };
}
const res = await fetch(url, opts);
let res;
try {
res = await fetch(url, opts);
// Server responded → reset failure counter and hide overlay if it was showing
if (_networkDown) _hideNetworkOverlay(true);
_networkFailCount = 0;
} catch (fetchErr) {
// Network-level failure (no route to host, Wi-Fi down, etc.)
_networkFailCount++;
if (_networkFailCount >= _NETWORK_FAIL_THRESHOLD) {
_showNetworkOverlay();
}
throw fetchErr;
}
if (!res.ok) {
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
@@ -3637,6 +3708,13 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
}
}
const data = await res.json();
// Keep local caches fresh for offline use (only ever written when server responds successfully)
if (action === 'inventory_list' && data && Array.isArray(data.inventory)) {
_offlineCacheSet(data.inventory);
}
if (action === 'get_settings' && data && data.success !== false) {
_offlineCacheSetSettings(data);
}
if (data && data.error) {
remoteLog('API_FAIL', `${action}: ${data.error}`);
}
@@ -13514,12 +13592,13 @@ function renderCookingStep() {
}).join('');
}
// Show ALL unused from_pantry ingredients (not filtered by step text).
// The AI often uses pronouns ("tagliarla", "aggiungile") instead of the ingredient
// name, so text-matching would miss them. Better to always show what's available.
const ings = (_cookingRecipe.ingredients || [])
.map((ing, idx) => ({ ...ing, _idx: idx }))
.filter(ing => ing.from_pantry && ing.product_id && ing.used !== true);
// Show ALL unused from_pantry ingredients only on the first step.
// On subsequent steps the ingredient panel stays hidden to avoid distraction.
const ings = _cookingStep === 0
? (_cookingRecipe.ingredients || [])
.map((ing, idx) => ({ ...ing, _idx: idx }))
.filter(ing => ing.from_pantry && ing.product_id && ing.used !== true)
: [];
const ingsEl = document.getElementById('cooking-step-ings');
if (ings.length > 0) {
@@ -15032,6 +15111,328 @@ function saveChatHistory() {
}).catch(() => {});
}
// ===== NETWORK ERROR OVERLAY + OFFLINE MODE =====
// ─────────────────────────────────────────────────────────────────────────────
// State
let _networkDown = false; // true while overlay is visible
let _networkFailCount = 0; // consecutive TypeError failures in api()
let _offlineMode = false; // true = overlay hidden, banner showing, cache reads/write queue
let _offlineBannerTimer = null; // auto-enter offline mode after delay
let _continueBtnTimer = null; // show "Continue offline" button after 3 s
const _NETWORK_FAIL_THRESHOLD = 3;
const _OFFLINE_MODE_DELAY_MS = 8000; // auto-enter offline mode after 8 s of overlay
const _OFFLINE_CACHE_KEY = '_evershelf_inv_cache';
const _OFFLINE_SETTINGS_KEY = '_evershelf_settings_cache';
const _OFFLINE_QUEUE_KEY = '_evershelf_op_queue';
// ─── Local cache helpers ────────────────────────────────────────────────────
function _offlineCacheGet() {
try { return JSON.parse(localStorage.getItem(_OFFLINE_CACHE_KEY)) || null; } catch { return null; }
}
function _offlineCacheSet(inventory) {
try { localStorage.setItem(_OFFLINE_CACHE_KEY, JSON.stringify(inventory)); } catch(e) {}
}
function _offlineCacheGetSettings() {
try { return JSON.parse(localStorage.getItem(_OFFLINE_SETTINGS_KEY)) || null; } catch { return null; }
}
function _offlineCacheSetSettings(settings) {
try { localStorage.setItem(_OFFLINE_SETTINGS_KEY, JSON.stringify(settings)); } catch(e) {}
}
function _offlineQueueGet() {
try { return JSON.parse(localStorage.getItem(_OFFLINE_QUEUE_KEY)) || []; } catch { return []; }
}
function _offlineQueueSet(q) {
try { localStorage.setItem(_OFFLINE_QUEUE_KEY, JSON.stringify(q)); } catch(e) {}
}
function _offlineQueuePush(action, body) {
const q = _offlineQueueGet();
if (q.length >= 100) q.shift(); // cap at 100
q.push({ action, body: body ? { ...body } : null, ts: Date.now() });
_offlineQueueSet(q);
_renderOfflineBanner();
}
// ─── Offline API handler: called by api() when _offlineMode is true ─────────
function _handleOfflineApi(action, params, body) {
// ─── Reads: computed from or served directly from local cache ────────────
if (action === 'inventory_list') {
let inv = _offlineCacheGet() || [];
// Client-side location filter so per-location views work correctly
if (params && params.location) inv = inv.filter(i => i.location === params.location);
return { success: true, inventory: inv, _offline: true };
}
if (action === 'inventory_summary') {
const inv = _offlineCacheGet() || [];
const byLoc = {};
inv.forEach(item => {
const loc = item.location || 'other';
if (!byLoc[loc]) byLoc[loc] = { location: loc, product_count: 0 };
byLoc[loc].product_count++;
});
return { success: true, summary: Object.values(byLoc), _offline: true };
}
if (action === 'stats') {
const inv = _offlineCacheGet() || [];
const now = Date.now();
const MS = 86400000;
const expiring_soon = inv.filter(i => {
if (!i.expiry_date || !(parseFloat(i.quantity) > 0)) return false;
const d = Math.floor((new Date(i.expiry_date).getTime() - now) / MS);
return d >= 0 && d <= 7;
});
const expired = inv.filter(i => {
if (!i.expiry_date || !(parseFloat(i.quantity) > 0)) return false;
return new Date(i.expiry_date).getTime() < now;
});
return {
success: true, _offline: true,
expiring_soon, expired,
// Mark is_edible:true so the banner's server-trust check produces no false positives offline.
// The client-side opened-shelf-life check in loadBannerAlerts() already handles genuinely
// expired opened items using estimateOpenedExpiryDays().
opened: inv.filter(i => i.opened_at && parseFloat(i.quantity) > 0).map(i => ({ ...i, is_edible: true })),
used_30d: 0, wasted_30d: 0, used_prev_30d: 0, wasted_prev_30d: 0,
used_prev_60d: 0, wasted_prev_60d: 0,
};
}
if (action === 'get_settings') {
const cached = _offlineCacheGetSettings();
if (cached) return { ...cached, _offline: true };
return { success: false, _offline: true };
}
if (action === 'get_settings') {
const cached = _offlineCacheGetSettings();
if (cached) return { ...cached, _offline: true };
return { success: false, _offline: true };
}
// Safe empty responses for read-only endpoints that can't be served from cache
const EMPTY_READS = {
'recent_popular_products': { success: true, recent: [], popular: [], recent_ids: [], _offline: true },
'consumption_predictions': { success: true, predictions: [], _offline: true },
'inventory_anomalies': { success: true, anomalies: [], _offline: true },
'inventory_finished_items':{ success: true, finished: [], _offline: true },
'shopping_list': { success: true, purchase: [], listUUID: '', _offline: true },
'bring_list': { success: true, purchase: [], listUUID: '', _offline: true },
'smart_shopping': { success: true, items: [], _offline: true },
'recipe_archive': { success: true, recipes: [], _offline: true },
'food_facts': { success: false, _offline: true },
'notifications': { success: true, notifications: [], _offline: true },
};
if (EMPTY_READS[action]) return EMPTY_READS[action];
// ─── Writes: queue and apply optimistic update to cache ──────────────────
const QUEUEABLE = ['inventory_update', 'inventory_use', 'inventory_delete',
'inventory_add', 'inventory_confirm_finished'];
if (QUEUEABLE.includes(action)) {
_offlineQueuePush(action, body);
_applyOptimisticUpdate(action, body);
return { success: true, _offline: true, _queued: true };
}
// Everything else (AI, Bring!, etc.): return offline error
return { success: false, error: 'offline', _offline: true };
}
// Optimistically update the cached inventory so the UI reflects the change immediately
function _applyOptimisticUpdate(action, body) {
if (!body) return;
const cache = _offlineCacheGet();
if (!cache) return;
let changed = false;
if (action === 'inventory_update' && body.id) {
const idx = cache.findIndex(i => i.id === body.id);
if (idx >= 0) { Object.assign(cache[idx], body); changed = true; }
} else if (action === 'inventory_use' && body.id) {
const idx = cache.findIndex(i => i.id === body.id);
if (idx >= 0) {
const used = parseFloat(body.qty ?? body.amount ?? 0);
cache[idx].quantity = Math.max(0, parseFloat(cache[idx].quantity ?? 0) - used);
changed = true;
}
} else if (action === 'inventory_delete' && body.id) {
const idx = cache.findIndex(i => i.id === body.id);
if (idx >= 0) { cache.splice(idx, 1); changed = true; }
} else if (action === 'inventory_add') {
cache.push({ ...body, id: -(Date.now()), _offline: true });
changed = true;
}
if (changed) _offlineCacheSet(cache);
}
// ─── Offline mode: banner + cache reads/write queue ─────────────────────────
function _enterOfflineMode() {
if (_offlineMode) return;
_offlineMode = true;
clearTimeout(_offlineBannerTimer);
clearTimeout(_continueBtnTimer);
// Hide the full overlay (no "restored" animation)
_hideNetworkOverlay(false);
// Unblock the UI and show a subtle offline indicator
document.body.classList.add('offline-mode');
_renderOfflineBanner(true); // loading state
// Load page content from the local cache, then update banner to "ready"
const p = refreshCurrentPage();
const afterLoad = () => _renderOfflineBanner(false);
if (p && typeof p.then === 'function') p.then(afterLoad).catch(afterLoad);
else setTimeout(afterLoad, 600);
}
async function _exitOfflineMode() {
_offlineMode = false; // first — so api() calls inside sync go to server
document.body.classList.remove('offline-mode');
const q = _offlineQueueGet();
if (q.length > 0) {
const synced = await _syncOfflineQueue();
if (synced > 0) {
showToast(t('error.offline_synced').replace('{n}', synced), 'success');
refreshCurrentPage();
}
}
const banner = document.getElementById('offline-banner');
if (banner) banner.style.display = 'none';
}
function _renderOfflineBanner(loading = false) {
const banner = document.getElementById('offline-banner');
const textEl = banner ? banner.querySelector('.offline-banner-text') : null;
if (!banner || !textEl) return;
const q = _offlineQueueGet();
if (q.length > 0) {
textEl.innerHTML = t('error.offline_ops_pending').replace('{n}', q.length);
} else if (loading) {
textEl.innerHTML = `<span class="offline-banner-dot"></span>${t('error.offline_reading_cache')}`;
} else {
const n = (_offlineCacheGet() || []).filter(i => parseFloat(i.quantity) > 0).length;
const msg = t('error.offline_cache_ready').replace('{n}', n);
textEl.innerHTML = msg;
}
banner.style.display = '';
}
async function _syncOfflineQueue() {
const q = _offlineQueueGet();
if (q.length === 0) return 0;
let synced = 0;
const failed = [];
const tempIdMap = {}; // negative temp id → real server id
for (const op of q) {
if (!op.action) continue;
let body = op.body ? { ...op.body } : {};
// Resolve any temp IDs from earlier add operations in this batch
if (typeof body.id === 'number' && body.id < 0 && tempIdMap[body.id]) {
body = { ...body, id: tempIdMap[body.id] };
}
try {
const result = await api(op.action, {}, 'POST', body);
if (result && result._offline) { failed.push(op); continue; }
if (op.action === 'inventory_add' && result?.id && op.body?.id < 0) {
tempIdMap[op.body.id] = result.id;
}
synced++;
} catch(_) { failed.push(op); }
}
_offlineQueueSet(failed);
return synced;
}
// ─── Overlay show / hide ────────────────────────────────────────────────────
function _showNetworkOverlay() {
if (_networkDown) return;
if (_offlineMode) return; // Already in offline mode — don't interrupt the user
_networkDown = true;
_networkFailCount = 0;
const el = document.getElementById('network-error-overlay');
if (!el) return;
el.classList.remove('restored', 'checking');
const titleEl = document.getElementById('net-error-title');
const subtitleEl = document.getElementById('net-error-subtitle');
const iconEl = document.getElementById('net-error-icon');
const statusEl = document.getElementById('net-error-status');
const contBtn = document.getElementById('net-error-continue-btn');
if (titleEl) titleEl.textContent = t('error.offline_title');
if (subtitleEl) subtitleEl.textContent = t('error.offline_subtitle');
if (iconEl) iconEl.textContent = '📡';
if (statusEl) statusEl.textContent = '';
if (contBtn) { contBtn.style.display = 'none'; contBtn.classList.remove('visible'); }
el.style.display = 'flex';
requestAnimationFrame(() => el.classList.add('visible'));
// Show "Continue offline" button after 3 s
clearTimeout(_continueBtnTimer);
_continueBtnTimer = setTimeout(() => {
if (!_networkDown) return;
if (contBtn) {
contBtn.textContent = t('error.offline_continue');
contBtn.style.display = '';
requestAnimationFrame(() => contBtn.classList.add('visible'));
}
}, 3000);
// Auto-enter offline mode after 8 s if user hasn't acted
clearTimeout(_offlineBannerTimer);
_offlineBannerTimer = setTimeout(() => {
if (_networkDown && !_offlineMode) _enterOfflineMode();
}, _OFFLINE_MODE_DELAY_MS);
}
function _hideNetworkOverlay(showRestoredMsg) {
clearTimeout(_continueBtnTimer);
clearTimeout(_offlineBannerTimer);
_networkDown = false;
_networkFailCount = 0;
const el = document.getElementById('network-error-overlay');
const contBtn = document.getElementById('net-error-continue-btn');
if (contBtn) { contBtn.style.display = 'none'; contBtn.classList.remove('visible'); }
if (!el) return;
if (showRestoredMsg) {
el.classList.add('restored');
el.classList.remove('checking');
const titleEl = document.getElementById('net-error-title');
const iconEl = document.getElementById('net-error-icon');
const statusEl = document.getElementById('net-error-status');
if (titleEl) titleEl.textContent = t('error.offline_restored');
if (iconEl) iconEl.textContent = '✅';
if (statusEl) statusEl.textContent = '';
setTimeout(() => {
el.classList.remove('visible');
setTimeout(() => { el.style.display = 'none'; el.classList.remove('restored', 'checking'); }, 450);
}, 1800);
} else {
el.classList.remove('visible');
setTimeout(() => { el.style.display = 'none'; el.classList.remove('restored', 'checking'); }, 450);
}
}
// Ping the server; if reachable call _handleServerRestored() via heartbeat
async function _networkPingOnce() {
const el = document.getElementById('network-error-overlay');
const iconEl = document.getElementById('net-error-icon');
const statusEl = document.getElementById('net-error-status');
if (el && el.classList.contains('visible')) {
el.classList.add('checking');
if (iconEl) iconEl.textContent = '🔄';
if (statusEl) statusEl.textContent = t('error.offline_checking');
}
try {
const res = await fetch(`${API_BASE}?action=ping&_t=${Date.now()}`, {
method: 'GET', cache: 'no-store',
signal: AbortSignal.timeout(4000),
});
if (res.ok) {
// Let heartbeat confirm the state authoritatively
_heartbeatRetry();
} else { throw new Error('not-ok'); }
} catch (_) {
if (el) el.classList.remove('checking');
if (iconEl) iconEl.textContent = '📡';
if (statusEl) statusEl.textContent = '';
}
}
// Browser-native online/offline events
window.addEventListener('offline', () => _showNetworkOverlay());
window.addEventListener('online', () => {
clearTimeout(_offlineBannerTimer); // don't auto-enter offline mode if we just came back
_networkPingOnce();
});
// ===== SCREENSAVER & INACTIVITY AUTO-REFRESH =====
let _inactivityTimer = null;
let _screensaverActive = false;
@@ -16074,19 +16475,61 @@ function _setServerOffline(offline) {
}
_serverOffline = offline;
document.body.classList.toggle('server-offline', offline);
const banner = document.getElementById('offline-banner');
if (banner) banner.style.display = offline ? '' : 'none';
if (offline) {
showToast(t('error.server_offline'), 'error');
if (!_offlineMode) {
// Show the full-screen network overlay (also auto-enters offline mode after 8 s)
_showNetworkOverlay();
}
// In offline mode the banner is already managed by _renderOfflineBanner()
} else {
showToast(t('error.server_restored'), 'success');
// Refresh the current page since updates may have been missed
refreshCurrentPage();
// Server came back: exit offline mode (sync queue, refresh) then hide overlay
_handleServerRestored();
}
_heartbeatTimer = setTimeout(_runHeartbeat,
offline ? _HB_INTERVAL_OFFLINE : _HB_INTERVAL_ONLINE);
}
/** Flush log messages and error reports that were buffered while offline. */
async function _flushOfflineReports() {
try {
const logs = JSON.parse(localStorage.getItem(_OFFLINE_LOGS_KEY) || '[]');
if (logs.length > 0) {
localStorage.removeItem(_OFFLINE_LOGS_KEY);
await fetch('api/index.php?action=client_log', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: logs })
});
}
} catch(e) {}
try {
const errors = JSON.parse(localStorage.getItem(_OFFLINE_ERRORS_KEY) || '[]');
if (errors.length > 0) {
localStorage.removeItem(_OFFLINE_ERRORS_KEY);
for (const errBody of errors) {
await fetch('api/index.php?action=report_error', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errBody)
}).catch(() => {});
}
}
} catch(e) {}
}
/** Async handler called when the server comes back online. */
async function _handleServerRestored() {
if (_offlineMode) {
await _exitOfflineMode();
}
// Now hide the overlay with the "restored" message (if still visible)
if (_networkDown) {
_hideNetworkOverlay(true);
}
// Flush all logs and error reports buffered while offline → GitHub issues when applicable
_flushOfflineReports().catch(() => {});
showToast(t('error.server_restored'), 'success');
refreshCurrentPage();
}
/** Called by the banner "Retry" button to trigger an immediate check. */
function _heartbeatRetry() {
clearTimeout(_heartbeatTimer);
@@ -16280,6 +16723,23 @@ async function _runStartupCheck() {
await new Promise(r => setTimeout(r, 600));
}
// ── Final step: sync local offline cache (inventory + settings) ──────────
// This ensures the offline copy is always fresh at startup while connected.
// The bar already shows 100%; we just update the label for a moment.
try {
setProgress(100, tl('syncing_local', 'Sincronizzazione dati locali...'), 'ok');
const [invData, settingsData] = await Promise.all([
fetch('api/index.php?action=inventory_list').then(r => r.json()).catch(() => null),
fetch('api/index.php?action=get_settings').then(r => r.json()).catch(() => null),
]);
if (invData && Array.isArray(invData.inventory)) _offlineCacheSet(invData.inventory);
if (settingsData && settingsData.success !== false) _offlineCacheSetSettings(settingsData);
setProgress(100, tl('sync_done', 'Dati locali aggiornati'), 'ok');
await new Promise(r => setTimeout(r, 400));
} catch(e) {
// Non-critical — app continues normally; cache may be stale or empty
}
wrapEl.style.display = 'none';
return true;
}
@@ -16401,6 +16861,24 @@ async function _initApp() {
initScreensaverShortcuts();
startBgShoppingRefresh();
startHeartbeat();
// ── Recover any pending offline operations left over from a previous session ──
// This handles the case where the user refreshed the page while offline ops
// were queued — the queue survives in localStorage but _offlineMode is false.
(() => {
const startupQueue = _offlineQueueGet();
if (startupQueue.length === 0) return;
setTimeout(async () => {
const synced = await _syncOfflineQueue();
await _flushOfflineReports().catch(() => {});
if (synced > 0) {
showToast(t('error.offline_synced').replace('{n}', synced), 'success');
refreshCurrentPage();
} else {
// All ops failed to sync — keep them for next attempt
showToast(t('error.offline_ops_pending').replace('{n}', _offlineQueueGet().length), 'warning');
}
}, 1200);
})();
_injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView)
// Sync version label in preloader (in case HTML is stale)
+14 -3
View File
@@ -64,7 +64,7 @@
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
<span class="app-preloader-version" id="preloader-version">v1.7.23</span>
<span class="app-preloader-version" id="preloader-version">v1.7.25</span>
</div>
</div>
@@ -77,7 +77,7 @@
<!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.23</span>
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.25</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -672,7 +672,7 @@
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
</div>
<div class="recipe-page-container">
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()" data-i18n="recipes.generate">
<button class="btn btn-large btn-success full-width recipe-generate-btn" onclick="openRecipeDialog()" data-i18n="recipes.generate">
✨ Genera nuova ricetta
</button>
<div id="recipe-archive" class="recipe-archive"></div>
@@ -1875,6 +1875,17 @@
</div>
</div>
<!-- ===== NETWORK ERROR OVERLAY ===== -->
<div id="network-error-overlay" style="display:none" aria-live="assertive" role="alert">
<div class="net-error-body">
<div class="net-error-icon" id="net-error-icon">📡</div>
<div class="net-error-title" id="net-error-title" data-i18n="error.offline_title">Nessuna connessione</div>
<div class="net-error-subtitle" id="net-error-subtitle" data-i18n="error.offline_subtitle">L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.</div>
<div class="net-error-status" id="net-error-status"></div>
<button class="net-error-continue-btn" id="net-error-continue-btn" onclick="_enterOfflineMode()" data-i18n="error.offline_continue" style="display:none">Continua in modalità offline</button>
</div>
</div>
<!-- ===== COOKING MODE OVERLAY ===== -->
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf",
"short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.24",
"version": "1.7.25",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+1394 -1382
View File
File diff suppressed because it is too large Load Diff
+1394 -1382
View File
File diff suppressed because it is too large Load Diff
+1341 -1329
View File
File diff suppressed because it is too large Load Diff
+1341 -1329
View File
File diff suppressed because it is too large Load Diff
+1394 -1382
View File
File diff suppressed because it is too large Load Diff