PageSourceSearch

https://web-production-89794.up.railway.app/js/offline.js

js web-production-89794.up.railway.app collected 2026-10-03 12:16:01 UTC 21,861 bytes, 544 lines download raw bytes

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.