1/* 2 * Anahuac RV Park Management System 3 * Copyright © 2026 Anahuac RV Park LLC. All Rights Reserved. 4 * Offline Mode Engine â IndexedDB queue + auto-sync 5 */ 6 7// --- IndexedDB Setup --- 8const OFFLINE_DB_NAME = 'rvpark_offline'; 9const OFFLINE_DB_VERSION = 1; 10let _offlineDb = null; 11 12function openOfflineDb() { 13 return new Promise((resolve, reject) => { 14 if (_offlineDb) return resolve(_offlineDb); 15 const req = indexedDB.open(OFFLINE_DB_NAME, OFFLINE_DB_VERSION); 16 req.onupgradeneeded = (e) => { 17 const db = e.target.result; 18 if (!db.objectStoreNames.contains('pending')) { 19 db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true }); 20 } 21 if (!db.objectStoreNames.contains('cache')) { 22 db.createObjectStore('cache', { keyPath: 'key' }); 23 } 24 }; 25 req.onsuccess = (e) => { _offlineDb = e.target.result; resolve(_offlineDb); }; 26 req.onerror = (e) => reject(e.target.error); 27 }); 28} 29 30// --- Pending Queue --- 31async function addPending(type, data) { 32 const db = await openOfflineDb(); 33 return new Promise((resolve, reject) => { 34 const tx = db.transaction('pending', 'readwrite'); 35 tx.objectStore('pending').add({ type, data, createdAt: new Date().toISOString() }); 36 tx.oncomplete = () => { updateSyncBadge(); resolve(); }; 37 tx.onerror = (e) => reject(e.target.error); 38 }); 39} 40 41async function getAllPending() { 42 const db = await openOfflineDb(); 43 return new Promise((resolve, reject) => { 44 const tx = db.transaction('pending', 'readonly'); 45 const req = tx.objectStore('pending').getAll(); 46 req.onsuccess = () => resolve(req.result || []); 47 req.onerror = (e) => reject(e.target.error); 48 }); 49} 50 51async function removePending(id) { 52 const db = await openOfflineDb(); 53 return new Promise((resolve, reject) => { 54 const tx = db.transaction('pending', 'readwrite'); 55 tx.objectStore('pending').delete(id); 56 tx.oncomplete = () => { updateSyncBadge(); resolve(); }; 57 tx.onerror = (e) => reject(e.target.error); 58 }); 59} 60 61async function clearAllPending() { 62 const db = await openOfflineDb(); 63 return new Promise((resolve, reject) => { 64 const tx = db.transaction('pending', 'readwrite'); 65 tx.objectStore('pending').clear(); 66 tx.oncomplete = () => { updateSyncBadge(); resolve(); }; 67 tx.onerror = (e) => reject(e.target.error); 68 }); 69} 70 71// --- Cache --- 72async function setCache(key, value) { 73 const db = await openOfflineDb(); 74 return new Promise((resolve, reject) => { 75 const tx = db.transaction('cache', 'readwrite'); 76 tx.objectStore('cache').put({ key, value, cachedAt: new Date().toISOString() }); 77 tx.oncomplete = () => resolve(); 78 tx.onerror = (e) => reject(e.target.error); 79 }); 80} 81 82async function getCache(key) { 83 const db = await openOfflineDb(); 84 return new Promise((resolve, reject) => { 85 const tx = db.transaction('cache', 'readonly'); 86 const req = tx.objectStore('cache').get(key); 87 req.onsuccess = () => resolve(req.result || null); 88 req.onerror = (e) => reject(e.target.error); 89 }); 90} 91 92// --- Online/Offline Detection --- 93let _isOnline = navigator.onLine; 94let _syncInterval = null; 95let _lastSyncTime = null; 96let _wasEverOffline = false; // Only show "back online" if we were actually offline 97 98function isOffline() { return !_isOnline; } 99 100function showOfflineBanner() { 101 if (navigator.onLine === true) return; 102 _wasEverOffline = true; 103 var banner = document.getElementById('offline-banner'); 104 if (!banner) { 105 banner = document.createElement('div'); 106 banner.id = 'offline-banner'; 107 banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:10000;background:linear-gradient(135deg,#d97706,#f59e0b);color:#1c1917;padding:10px 16px;text-align:center;font-size:0.88rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,0.2);display:flex;align-items:center;justify-content:center;gap:8px;cursor:pointer'; 108 banner.onclick = showEmergencyGuide; 109 document.body.appendChild(banner); 110 } 111 banner.innerHTML = 'ð¡ OFFLINE MODE â No internet connection. Data saved locally. <span style="text-decoration:underline;margin-left:4px">Emergency Guide â</span>'; 112 banner.style.display = ''; 113 updateConnectionDot('offline'); 114 // Show emergency guide on first offline event 115 if (!window._emergencyGuideShown) { window._emergencyGuideShown = true; showEmergencyGuide(); } 116} 117 118function showOnlineBanner() { 119 if (!_wasEverOffline) return; 120 var banner = document.getElementById('offline-banner'); 121 if (banner) { 122 banner.style.background = 'linear-gradient(135deg,#16a34a,#22c55e)'; 123 banner.style.color = '#fff'; 124 banner.innerHTML = 'â Back Online! Syncing your offline data now...'; 125 banner.style.display = ''; 126 setTimeout(function() { banner.style.display = 'none'; }, 4000); 127 } 128 updateConnectionDot('online'); 129 _wasEverOffline = false;
130} 131 132function updateConnectionDot(state) { 133 var dot = document.getElementById('connection-dot'); 134 if (!dot) { 135 dot = document.createElement('div'); 136 dot.id = 'connection-dot'; 137 dot.style.cssText = 'position:fixed;top:8px;right:60px;z-index:9999;display:flex;align-items:center;gap:4px;font-size:0.68rem;font-weight:600;padding:3px 8px;border-radius:12px;background:rgba(255,255,255,0.95);box-shadow:0 1px 4px rgba(0,0,0,0.15)'; 138 document.body.appendChild(dot); 139 } 140 if (state === 'offline') { dot.innerHTML = '<span style="width:8px;height:8px;border-radius:50%;background:#dc2626;display:inline-block"></span> Offline'; dot.style.color = '#dc2626'; } 141 else if (state === 'syncing') { dot.innerHTML = '<span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;display:inline-block;animation:pulse 1s infinite"></span> Syncing'; dot.style.color = '#f59e0b'; } 142 else { dot.innerHTML = '<span style="width:8px;height:8px;border-radius:50%;background:#16a34a;display:inline-block"></span> Online'; dot.style.color = '#16a34a'; } 143} 144 145function showEmergencyGuide() { 146 var existing = document.getElementById('emergency-guide-overlay'); 147 if (existing) { existing.remove(); return; } 148 var overlay = document.createElement('div'); 149 overlay.id = 'emergency-guide-overlay'; 150 overlay.style.cssText = 'position:fixed;inset:0;z-index:20000;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;padding:1rem'; 151 overlay.innerHTML = 152 '<div style="background:#fff;border-radius:16px;max-width:520px;width:100%;max-height:90vh;overflow-y:auto;padding:1.5rem">' + 153 '<h2 style="margin:0 0 0.75rem;color:#d97706;font-size:1.2rem">ð¡ No Internet Connection</h2>' + 154 '<div style="background:#f0fdf4;border:1px solid #a7f3d0;border-radius:8px;padding:0.6rem 0.75rem;margin-bottom:0.75rem;font-size:0.85rem;color:#065f46">' + 155 '<strong>Your data is saved on this device.</strong> Everything syncs automatically when you reconnect.</div>' + 156 '<h4 style="margin:0.75rem 0 0.35rem;color:#1c1917">Works offline:</h4>' + 157 '<div style="font-size:0.85rem;line-height:1.7;color:#44403c">' + 158 'â View your park â dashboard, residents, sites, arrivals, meter readings<br>' + 159 'â Check a guest in<br>' + 160 'â Add a new resident<br>' + 161 'â Log meter readings<br>' + 162 'â Emergency contacts & forms</div>' + 163 '<h4 style="margin:0.75rem 0 0.35rem;color:#1c1917">Available when you\'re back online:</h4>' + 164 '<div style="font-size:0.85rem;line-height:1.7;color:#78716c">' + 165 'ð¶ Take a payment<br>' + 166 'ð¶ Check out / settle a move-out<br>' + 167 'ð¶ Send confirmation texts<br>' + 168 'ð¶ Reports & tax documents</div>' + 169 '<h4 style="margin:0.75rem 0 0.35rem;color:#1c1917">Emergency Contacts:</h4>' + 170 '<div style="font-size:0.88rem;line-height:1.8;color:#1c1917">' + 171 'ð¨ <strong>911</strong> â Life emergencies<br>' + 172 'ð <strong>(409) 267-6603</strong> â Park office<br>' + 173 'ð <strong>(409) 267-2500</strong> â Chambers County OEM<br>' + 174 'â¡ <strong>1-800-968-8243</strong> â Entergy outages</div>' + 175 '<h4 style="margin:0.75rem 0 0.35rem;color:#1c1917">ð± Use Phone as Hotspot:</h4>' + 176 '<div style="font-size:0.82rem;line-height:1.5;color:#44403c">' + 177 '<strong>iPhone:</strong> Settings â Personal Hotspot â Allow Others to Join<br>' + 178 '<strong>Android:</strong> Settings â Network â Hotspot â Turn On<br>' + 179 '<span style="color:#78716c">Your phone\'s data can keep the app running when WiFi is down!</span></div>' + 180 '<button onclick="document.getElementById(\'emergency-guide-overlay\').remove()" style="display:block;width:100%;margin-top:1rem;padding:0.75rem;background:#1a5c32;color:#fff;border:none;border-radius:10px;font-size:0.95rem;font-weight:700;cursor:pointer">Got it â Continue Offline</button>' + 181 '</div>'; 182 document.body.appendChild(overlay); 183} 184 185function hideOfflineBanner() { 186 var banner = document.getElementById('offline-banner'); 187 if (banner) banner.style.display = 'none'; 188} 189 190window.addEventListener('online', function() { 191 _isOnline = true; 192 showOnlineBanner(); 193 syncPendingRecords();
194 // Re-prime cache on reconnect (refreshes stale copies + covers first-load-offline) 195 setTimeout(function() { try { _primeOfflineCache(); } catch (e) {} }, 2000); 196}); 197 198window.addEventListener('offline', function() { 199 _isOnline = false; 200 showOfflineBanner(); 201}); 202 203// --- Sync Engine --- 204async function syncPendingRecords() { 205 if (!_isOnline || !API?.token) return; 206 const pending = await getAllPending(); 207 if (!pending.length) { updateSyncBadge(); return; } 208 209 updateConnectionDot('syncing'); 210 let synced = 0; 211 for (const item of pending) { 212 try { 213 const endpoint = { 214 checkin: '/checkins/checkin', 215 meter: '/meters', 216 payment: '/payments', 217 expense: '/expenses', 218 water_reading: '/water-meters/readings', 219 note: '/tenants/' + (item.data?.tenant_id || 0), 220 }[item.type]; 221 if (!endpoint) { await removePending(item.id); continue; } 222 var method = item.type === 'note' ? 'put' : 'post'; 223 if (method === 'put') await API.put(endpoint, item.data); 224 else await API.post(endpoint, item.data); 225 await removePending(item.id); 226 synced++; 227 } catch (err) { 228 console.error('[offline-sync] failed to sync ' + item.type + ':', err.message); 229 } 230 } 231 232 _lastSyncTime = new Date(); 233 updateSyncBadge(); 234 updateSyncStatus(); 235 updateConnectionDot('online'); 236 237 if (synced > 0) { 238 if (typeof showStatusToast === 'function') { 239 showStatusToast('â ', 'Synced â ' + synced + ' change' + (synced > 1 ? 's' : '') + ' sent'); 240 } 241 // Refresh current page to show synced data 242 if (typeof navigateTo === 'function' && typeof currentPage !== 'undefined') { 243 navigateTo(currentPage); 244 } 245 } 246} 247 248// Auto-sync every 30 seconds when online 249function startSyncEngine() { 250 if (_syncInterval) clearInterval(_syncInterval); 251 _syncInterval = setInterval(() => { 252 if (_isOnline) syncPendingRecords(); 253 }, 30000); 254} 255 256// --- UI Helpers --- 257async function updateSyncBadge() { 258 try { 259 const pending = await getAllPending(); 260 const count = pending.length; 261 // Pending-sync pill â bottom-right, brand green, shows count + label 262 let badge = document.getElementById('sync-badge'); 263 if (count > 0) { 264 if (!badge) { 265 badge = document.createElement('div'); 266 badge.id = 'sync-badge'; 267 badge.style.cssText = 'position:fixed;bottom:16px;right:16px;background:#1a5c32;color:#fff;font-size:0.78rem;font-weight:700;padding:6px 14px;border-radius:20px;z-index:9997;box-shadow:0 2px 8px rgba(0,0,0,0.2);display:flex;align-items:center;gap:6px'; 268 document.body.appendChild(badge); 269 } 270 badge.innerHTML = '<span style="font-size:0.9rem">â¬</span> ' + count + ' waiting to sync'; 271 badge.style.display = 'flex'; 272 } else if (badge) { 273 badge.style.display = 'none'; 274 } 275 } catch {} 276} 277 278function updateSyncStatus() { 279 const el = document.getElementById('sync-status'); 280 if (!el) return; 281 if (_lastSyncTime) { 282 const ago = Math.round((Date.now() - _lastSyncTime.getTime()) / 1000); 283 el.textContent = ago < 60 ? 'Just now' : `${Math.round(ago / 60)}m ago`; 284 } 285} 286 287// --- Cache operational data for offline lookup --- 288// Only cache data needed for offline forms â NEVER cache financial data 289// (invoices, balances, payments, P&L) to avoid stale-money bugs. 290var OFFLINE_CACHEABLE = { 291 '/tenants': 'tenants', 292 '/tenants/all': 'tenants_all', 293 '/lots': 'lots', 294 '/checkins': 'checkins', 295 '/meters/latest': 'meters_latest', 296 '/dashboard': 'dashboard', 297 '/settings': 'settings', 298 '/dashboard/calendar': 'dashboard_calendar' 299}; 300 301// Pages that CANNOT work offline (financial/reporting) â show a friendly message instead of erroring 302var ONLINE_ONLY_PAGES = ['billing', 'payments', 'pnl', 'reports', 'bank-recon', 'invoices', 'expenses', 'receipt-catalog']; 303 304async function cacheEndpoint(key, data) { 305 if (!data) return; 306 try { await setCache(key, data); } catch {} 307} 308 309async function getCachedEndpoint(key) { 310 try { 311 const cached = await getCache(key); 312 return cached ? { data: cached.value, cachedAt: cached.cachedAt } : null; 313 } catch { return null; } 314} 315 316// Legacy aliases 317async function cacheTenants(tenants) { await cacheEndpoint('tenants', tenants); } 318async function getCachedTenants() { return getCachedEndpoint('tenants'); } 319 320// --- Offline-aware API wrapper --- 321// Patches API.get/post to serve cached data and queue writes when offline. 322// ONLY caches operational data (tenants, lots, checkins, meters) â never financial data. 323// A caught error is a CONNECTIVITY failure (not an HTTP error from the server) when: 324// - fetch() itself threw (TypeError â "Failed to fetch" / "NetworkError"), OR 325// - the error name is TypeError. 326// An HTTP error (4xx/5xx) is thrown by API.request as a plain Error with the server's 327// message â that must NOT trigger offline fallback. 328function _isConnectivityError(err) { 329 return err instanceof TypeError || (err && err.name === 'TypeError'); 330} 331 332function patchApiForOffline() { 333 if (!window.API || API._offlinePatched) return; 334 335 // Operational writes safe to queue offline. Money writes (/payments, /expenses) 336 // intentionally excluded â they require signal to avoid queuing unverified amounts. 337 var offlineTypes = { 338 '/checkins/checkin': 'checkin', 339 '/tenants': 'checkin', 340 '/meters': 'meter', 341 '/water-meters/readings': 'water_reading', 342 }; 343 344 const origPost = API.post.bind(API); 345 API.post = async function(url, data) { 346 // Fast path: known offline â queue immediately 347 if (!_isOnline) { 348 return _offlineQueuePost(url, data); 349 } 350 // Try network 351 try { 352 return await origPost(url, data); 353 } catch (err) { 354 // Connectivity failure â queue if supported, else rethrow 355 if (_isConnectivityError(err)) { 356 _isOnline = false;
357 showOfflineBanner(); 358 return _offlineQueuePost(url, data); 359 } 360 throw err; // HTTP error â propagate as-is 361 } 362 }; 363 364 function _offlineQueuePost(url, data) { 365 for (var path in offlineTypes) { 366 if (url === path || url.startsWith(path + '/')) { 367 addPending(offlineTypes[path], data); 368 if (typeof showStatusToast === 'function') showStatusToast('ð¤', 'Saved offline â will sync when connected'); 369 return { id: 'offline-' + Date.now(), offline: true }; 370 } 371 } 372 throw new Error('This action requires an internet connection.'); 373 } 374 375 var GET_TIMEOUT_MS = 8000; // fail-fast: if no response in 8s, treat as offline 376 377 const origGet = API.get.bind(API); 378 API.get = async function(url) { 379 var baseUrl = url.split('?')[0]; 380 var cacheKey = OFFLINE_CACHEABLE[baseUrl] || null; 381 382 // Fast path: known offline + cacheable â serve from IndexedDB 383 if (!_isOnline && cacheKey) { 384 return _offlineServeGet(cacheKey); 385 } 386 387 // Try network with fail-fast timeout (race) 388 try { 389 var result = await _racedGet(origGet, url, GET_TIMEOUT_MS); 390 // Cache operational endpoints on success 391 if (cacheKey) cacheEndpoint(cacheKey, result); 392 return result; 393 } catch (err) { 394 // Connectivity failure OR timeout â try cache fallback 395 if (_isConnectivityError(err) || (err && err._offlineTimeout)) { 396 _isOnline = false; 397 showOfflineBanner(); 398 if (cacheKey) return _offlineServeGet(cacheKey); 399 throw new Error('You are offline. This data is not available.'); 400 } 401 throw err; // HTTP error â propagate as-is 402 } 403 }; 404 405 // Race the network GET against a timeout. On timeout, reject with a 406 // marked error so the catch can distinguish it from HTTP errors. 407 function _racedGet(fn, url, ms) { 408 return new Promise(function(resolve, reject) { 409 var done = false; 410 var timer = setTimeout(function() { 411 if (done) return; 412 done = true; 413 var err = new Error('Network timeout'); 414 err._offlineTimeout = true; 415 reject(err); 416 }, ms); 417 fn(url).then(function(result) { 418 if (done) return; 419 done = true; 420 clearTimeout(timer); 421 resolve(result); 422 }).catch(function(err) { 423 if (done) return; 424 done = true; 425 clearTimeout(timer); 426 reject(err); 427 }); 428 }); 429 } 430 431 async function _offlineServeGet(cacheKey) { 432 var cached = await getCachedEndpoint(cacheKey); 433 if (cached) { 434 console.log('[offline] serving cached ' + cacheKey + ' from ' + cached.cachedAt); 435 _showOfflineDataBanner(cached.cachedAt); 436 return cached.data; 437 } 438 throw new Error('You are offline. This data is not available.'); 439 } 440 441 API._offlinePatched = true; 442} 443 444// Show a small banner indicating cached data age 445function _showOfflineDataBanner(cachedAt) { 446 var existing = document.getElementById('offline-data-age'); 447 if (existing) existing.remove(); 448 var ago = ''; 449 try { 450 var diff = Date.now() - new Date(cachedAt).getTime(); 451 if (diff < 60000) ago = 'just now'; 452 else if (diff < 3600000) ago = Math.floor(diff / 60000) + 'm ago'; 453 else ago = Math.floor(diff / 3600000) + 'h ago'; 454 } catch { ago = ''; } 455 var el = document.createElement('div'); 456 el.id = 'offline-data-age'; 457 el.style.cssText = 'position:fixed;bottom:60px;left:50%;transform:translateX(-50%);z-index:9998;background:#fef3c7;color:#92400e;font-size:0.75rem;font-weight:600;padding:4px 12px;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,0.15)'; 458 el.textContent = 'Offline â showing data as of ' + ago; 459 document.body.appendChild(el); 460 // Remove after 8 seconds 461 setTimeout(function() { if (el.parentNode) el.remove(); }, 8000); 462} 463 464// Show a friendly message for online-only pages 465function showOnlineOnlyMessage() { 466 var content = document.getElementById('page-content'); 467 if (!content) return; 468 content.innerHTML = '<div class="card" style="text-align:center;padding:2rem;margin-top:2rem">' + 469 '<div style="font-size:2rem;margin-bottom:0.5rem">ð¡</div>' + 470 '<h3 style="color:#78716c;margin-bottom:0.5rem">This page needs an internet connection</h3>' + 471 '<p style="color:#a8a29e;font-size:0.85rem">Financial data (billing, payments, invoices) requires a live connection to ensure accuracy.</p>' + 472 '<p style="color:#a8a29e;font-size:0.82rem;margin-top:0.5rem">You can still use: Check-ins, Meter Readings, Tenant List, and Site Map while offline.</p>' + 473 '</div>'; 474} 475 476// --- Initialize --- 477function initOfflineMode() { 478 _isOnline = navigator.onLine; 479 openOfflineDb().then(function() { 480 updateSyncBadge(); 481 startSyncEngine(); 482 patchApiForOffline(); 483 // Only show offline banner if genuinely offline â never on normal page load 484 if (!navigator.onLine) { 485 showOfflineBanner(); 486 } 487 // Always hide any stale banner when online 488 if (navigator.onLine) { 489 hideOfflineBanner(); 490 } 491 console.log('[offline] initialized, online=' + navigator.onLine); 492 }).catch(function(err) { 493 console.error('[offline] init failed:', err); 494 }); 495} 496
497// Background cache prime â fetches each cacheable endpoint once so the IndexedDB 498// cache is warm for offline use. Runs AFTER initial render (5s delay), only when 499// online, only once per session. Uses the real API.get (which stores results via 500// the patched cacheEndpoint path). Failures are silently ignored. 501var _primeInFlight = false; 502async function _primeOfflineCache() { 503 if (_primeInFlight || !_isOnline || !API?.token) return; 504 _primeInFlight = true; 505 console.log('[offline] priming cache...'); 506 // Compute current-week range for the calendar endpoint 507 var now = new Date(); 508 var dow = now.getDay(); 509 var sun = new Date(now); sun.setDate(now.getDate() - dow); 510 var sat = new Date(sun); sat.setDate(sun.getDate() + 6); 511 function pad2(n) { return n < 10 ? '0' + n : '' + n; } 512 function fmt(d) { return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); } 513 var calFrom = fmt(sun), calTo = fmt(sat); 514 515 var endpoints = [ 516 '/dashboard', 517 '/tenants', 518 '/tenants/all', 519 '/lots', 520 '/checkins', 521 '/meters/latest', 522 '/settings', 523 '/dashboard/calendar?from=' + calFrom + '&to=' + calTo 524 ]; 525 for (var i = 0; i < endpoints.length; i++) { 526 try { await API.get(endpoints[i]); } catch (e) { /* ignore â non-critical */ } 527 } 528 console.log('[offline] cache primed (' + endpoints.length + ' endpoints)'); 529 _primeInFlight = false; 530} 531 532// Auto-init after a short delay to let API load 533setTimeout(initOfflineMode, 1000); 534 535// Prime cache 5s after load (after dashboard has rendered) â non-blocking 536setTimeout(function() { try { _primeOfflineCache(); } catch (e) {} }, 5000); 537 538// Nuclear safety: kill offline banner 3 seconds after load if online 539setTimeout(function() { 540 if (navigator.onLine) { 541 var b = document.getElementById('offline-banner'); 542 if (b) b.remove(); 543 } 544}, 3000);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.