feat(offline): full offline mode — cache sync, write queue, startup recovery
- Full-screen network error overlay (z-index 300000, above screensaver) - 'Continue offline' button after 3s, auto-enter after 8s - Inventory + settings synced to localStorage at startup (during health check) - inventory_summary and stats computed from local cache while offline - Write queue (add/use/update/delete): optimistic UI + sync on reconnect - Pending ops survive page refresh — detected and re-synced at next startup - Buffered remoteLog/reportError flushed to server (GitHub issues) on restore - AI/network sections hidden in offline mode (CSS body.offline-mode) - Banner: pulsing dot while loading cache, item count when ready - Broken external images replaced with grey SVG placeholder - Fix: opened items marked is_edible:true offline (was flooding banner) - Fix: _semverGt() prevents update badge for older GitHub releases - Bump version to v1.7.25
This commit is contained in:
+12
-7
@@ -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.
|
- **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
|
## [1.7.25] - 2026-05-25
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
### Added
|
### 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
|
## [1.7.24] - 2026-05-21
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||||
- **Installable** — Add to home screen for a native app experience
|
- **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
|
- **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)
|
### ⚖️ Smart Scale Integration (Add-on)
|
||||||
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
||||||
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
||||||
|
|||||||
+139
-1
@@ -596,13 +596,37 @@ body {
|
|||||||
}
|
}
|
||||||
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
|
.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 */
|
/* 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;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: opacity 0.3s;
|
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 {
|
body.server-offline .bottom-nav {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -7655,3 +7679,117 @@ body.cooking-mode-active .app-header {
|
|||||||
|
|
||||||
/* ── Appliance remove active ── */
|
/* ── Appliance remove active ── */
|
||||||
[data-theme="dark"] .appliance-item .appliance-remove:active { background: #2a0808; }
|
[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
@@ -13,6 +13,8 @@
|
|||||||
// 2. reportError() — immediate single POST → report_error endpoint → GitHub Issue
|
// 2. reportError() — immediate single POST → report_error endpoint → GitHub Issue
|
||||||
|
|
||||||
const _remoteLogBuffer = [];
|
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;
|
let _remoteLogTimer = null;
|
||||||
const _origConsoleError = console.error.bind(console);
|
const _origConsoleError = console.error.bind(console);
|
||||||
const _origConsoleWarn = console.warn.bind(console);
|
const _origConsoleWarn = console.warn.bind(console);
|
||||||
@@ -33,11 +35,25 @@ function flushRemoteLog() {
|
|||||||
_remoteLogTimer = null;
|
_remoteLogTimer = null;
|
||||||
if (_remoteLogBuffer.length === 0) return;
|
if (_remoteLogBuffer.length === 0) return;
|
||||||
const msgs = _remoteLogBuffer.splice(0);
|
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`, {
|
fetch(`api/index.php?action=client_log`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ messages: msgs })
|
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
|
// Override console.error and console.warn to also send remotely
|
||||||
@@ -65,21 +81,48 @@ function reportError(payload) {
|
|||||||
version: document.querySelector('.header-version')?.textContent?.trim() || '',
|
version: document.querySelector('.header-version')?.textContent?.trim() || '',
|
||||||
url: location.href,
|
url: location.href,
|
||||||
user_agent: navigator.userAgent,
|
user_agent: navigator.userAgent,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
}, payload);
|
}, 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', {
|
fetch('api/index.php?action=report_error', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
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.
|
// 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 ───────────────────────────────────────────────
|
// ── Webapp update notification ───────────────────────────────────────────────
|
||||||
// Checks both the deployed webapp version and the latest GitHub release.
|
// Checks both the deployed webapp version and the latest GitHub release.
|
||||||
// Fires on tab focus and every 5 minutes.
|
// Fires on tab focus and every 5 minutes.
|
||||||
const _loadedVersion = (document.querySelector('.header-version')?.textContent?.trim() || '').replace(/^v/, '');
|
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 ────────────────────────────────────────────────────
|
// ── Gemini AI availability ────────────────────────────────────────────────────
|
||||||
// Set to true in _initApp / syncSettingsFromDB once server confirms key is set.
|
// Set to true in _initApp / syncSettingsFromDB once server confirms key is set.
|
||||||
// All AI entry points call _requireGemini() before opening camera / API calls.
|
// 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() {
|
function _checkWebappUpdate() {
|
||||||
const STORAGE_KEY = '_evershelf_update_checked_at';
|
const STORAGE_KEY = '_evershelf_update_checked_at';
|
||||||
const SEEN_KEY = '_evershelf_update_seen_ts';
|
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 ──
|
// ── Check 1: server has a newer version deployed since this page loaded ──
|
||||||
const serverVer = (data.webapp_version || '').replace(/^v/, '');
|
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 ──
|
// ── Check 2: a newer GitHub release not yet acknowledged ──
|
||||||
const publishedAt = data.published_at || '';
|
const publishedAt = data.published_at || '';
|
||||||
@@ -159,7 +213,7 @@ function _checkWebappUpdate() {
|
|||||||
const latestTag = (data.latest_tag || '').replace(/^v/, '');
|
const latestTag = (data.latest_tag || '').replace(/^v/, '');
|
||||||
const releaseNewer = publishedAt && publishedAt !== seenTs &&
|
const releaseNewer = publishedAt && publishedAt !== seenTs &&
|
||||||
/^\d+\.\d+/.test(latestTag) &&
|
/^\d+\.\d+/.test(latestTag) &&
|
||||||
_loadedVersion && latestTag !== _loadedVersion;
|
_loadedVersion && _semverGt(latestTag, _loadedVersion);
|
||||||
|
|
||||||
if (!deployChanged && !releaseNewer) return;
|
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 };
|
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}`;
|
let url = `${API_BASE}?action=${action}`;
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
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) {
|
} else if (Object.keys(extraHeaders).length > 0) {
|
||||||
opts.headers = { ...extraHeaders };
|
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) {
|
if (!res.ok) {
|
||||||
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
|
remoteLog('API_ERROR', `${action} HTTP ${res.status}`);
|
||||||
// Report HTTP 5xx as server errors (not 4xx which are usually user errors)
|
// 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();
|
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) {
|
if (data && data.error) {
|
||||||
remoteLog('API_FAIL', `${action}: ${data.error}`);
|
remoteLog('API_FAIL', `${action}: ${data.error}`);
|
||||||
}
|
}
|
||||||
@@ -13514,12 +13592,13 @@ function renderCookingStep() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show ALL unused from_pantry ingredients (not filtered by step text).
|
// Show ALL unused from_pantry ingredients only on the first step.
|
||||||
// The AI often uses pronouns ("tagliarla", "aggiungile") instead of the ingredient
|
// On subsequent steps the ingredient panel stays hidden to avoid distraction.
|
||||||
// name, so text-matching would miss them. Better to always show what's available.
|
const ings = _cookingStep === 0
|
||||||
const ings = (_cookingRecipe.ingredients || [])
|
? (_cookingRecipe.ingredients || [])
|
||||||
.map((ing, idx) => ({ ...ing, _idx: idx }))
|
.map((ing, idx) => ({ ...ing, _idx: idx }))
|
||||||
.filter(ing => ing.from_pantry && ing.product_id && ing.used !== true);
|
.filter(ing => ing.from_pantry && ing.product_id && ing.used !== true)
|
||||||
|
: [];
|
||||||
|
|
||||||
const ingsEl = document.getElementById('cooking-step-ings');
|
const ingsEl = document.getElementById('cooking-step-ings');
|
||||||
if (ings.length > 0) {
|
if (ings.length > 0) {
|
||||||
@@ -15032,6 +15111,328 @@ function saveChatHistory() {
|
|||||||
}).catch(() => {});
|
}).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 =====
|
// ===== SCREENSAVER & INACTIVITY AUTO-REFRESH =====
|
||||||
let _inactivityTimer = null;
|
let _inactivityTimer = null;
|
||||||
let _screensaverActive = false;
|
let _screensaverActive = false;
|
||||||
@@ -16074,19 +16475,61 @@ function _setServerOffline(offline) {
|
|||||||
}
|
}
|
||||||
_serverOffline = offline;
|
_serverOffline = offline;
|
||||||
document.body.classList.toggle('server-offline', offline);
|
document.body.classList.toggle('server-offline', offline);
|
||||||
const banner = document.getElementById('offline-banner');
|
|
||||||
if (banner) banner.style.display = offline ? '' : 'none';
|
|
||||||
if (offline) {
|
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 {
|
} else {
|
||||||
showToast(t('error.server_restored'), 'success');
|
// Server came back: exit offline mode (sync queue, refresh) then hide overlay
|
||||||
// Refresh the current page since updates may have been missed
|
_handleServerRestored();
|
||||||
refreshCurrentPage();
|
|
||||||
}
|
}
|
||||||
_heartbeatTimer = setTimeout(_runHeartbeat,
|
_heartbeatTimer = setTimeout(_runHeartbeat,
|
||||||
offline ? _HB_INTERVAL_OFFLINE : _HB_INTERVAL_ONLINE);
|
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. */
|
/** Called by the banner "Retry" button to trigger an immediate check. */
|
||||||
function _heartbeatRetry() {
|
function _heartbeatRetry() {
|
||||||
clearTimeout(_heartbeatTimer);
|
clearTimeout(_heartbeatTimer);
|
||||||
@@ -16280,6 +16723,23 @@ async function _runStartupCheck() {
|
|||||||
await new Promise(r => setTimeout(r, 600));
|
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';
|
wrapEl.style.display = 'none';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -16401,6 +16861,24 @@ async function _initApp() {
|
|||||||
initScreensaverShortcuts();
|
initScreensaverShortcuts();
|
||||||
startBgShoppingRefresh();
|
startBgShoppingRefresh();
|
||||||
startHeartbeat();
|
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)
|
_injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView)
|
||||||
|
|
||||||
// Sync version label in preloader (in case HTML is stale)
|
// Sync version label in preloader (in case HTML is stale)
|
||||||
|
|||||||
+14
-3
@@ -64,7 +64,7 @@
|
|||||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||||
<div id="preloader-error-msg" class="preloader-error-msg" 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<!-- Title — left-aligned; grows to fill space -->
|
<!-- Title — left-aligned; grows to fill space -->
|
||||||
<div class="header-title-wrap">
|
<div class="header-title-wrap">
|
||||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
<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>
|
</h1>
|
||||||
<!-- Update badge — shown alongside title, never replaces it -->
|
<!-- Update badge — shown alongside title, never replaces it -->
|
||||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||||
@@ -672,7 +672,7 @@
|
|||||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-page-container">
|
<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
|
✨ Genera nuova ricetta
|
||||||
</button>
|
</button>
|
||||||
<div id="recipe-archive" class="recipe-archive"></div>
|
<div id="recipe-archive" class="recipe-archive"></div>
|
||||||
@@ -1875,6 +1875,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ===== -->
|
<!-- ===== COOKING MODE OVERLAY ===== -->
|
||||||
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
|
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
|
||||||
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
|
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "EverShelf",
|
"name": "EverShelf",
|
||||||
"short_name": "EverShelf",
|
"short_name": "EverShelf",
|
||||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||||
"version": "1.7.24",
|
"version": "1.7.25",
|
||||||
"start_url": "/evershelf/",
|
"start_url": "/evershelf/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f0f4e8",
|
"background_color": "#f0f4e8",
|
||||||
|
|||||||
+1394
-1382
File diff suppressed because it is too large
Load Diff
+1394
-1382
File diff suppressed because it is too large
Load Diff
+1341
-1329
File diff suppressed because it is too large
Load Diff
+1341
-1329
File diff suppressed because it is too large
Load Diff
+1394
-1382
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user