← back to Watches

public/sw-advanced.js

541 lines

/**
 * Advanced Service Worker for Omega Watch Price History PWA
 * Features: Offline mode, push notifications, background sync, camera caching
 * Version: 2.0.0
 */

const CACHE_VERSION = 'omega-watches-v2.0.0';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const API_CACHE = `${CACHE_VERSION}-api`;
const IMAGE_CACHE = `${CACHE_VERSION}-images`;
const CAMERA_CACHE = `${CACHE_VERSION}-camera`;
const AR_CACHE = `${CACHE_VERSION}-ar`;

// Critical assets for offline functionality
const CRITICAL_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/manifest.json',
  '/pwa-app.js',
  '/pwa-styles.css',
  '/camera-scanner.js',
  '/ar-viewer.js',
  '/biometric-auth.js'
];

// API endpoints to cache for offline use
const API_ENDPOINTS_TO_CACHE = [
  '/api/watches',
  '/api/statistics',
  '/api/collections',
  '/api/watches/trending'
];

// Install event - cache critical assets
self.addEventListener('install', (event) => {
  console.log('[SW] Installing service worker v2.0.0...');

  event.waitUntil(
    Promise.all([
      caches.open(STATIC_CACHE).then(cache => {
        console.log('[SW] Caching critical assets');
        return cache.addAll(CRITICAL_ASSETS.map(url => new Request(url, { cache: 'reload' })))
          .catch(err => {
            console.warn('[SW] Some assets failed to cache:', err);
            // Continue anyway - we'll cache them on demand
            return Promise.resolve();
          });
      }),
      caches.open(API_CACHE).then(cache => {
        console.log('[SW] Pre-caching API endpoints');
        return Promise.all(
          API_ENDPOINTS_TO_CACHE.map(url =>
            fetch(url)
              .then(response => cache.put(url, response))
              .catch(() => console.log('[SW] Skipping API cache:', url))
          )
        );
      })
    ]).then(() => {
      console.log('[SW] Installation complete');
      return self.skipWaiting(); // Activate immediately
    })
  );
});

// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating service worker...');

  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames
          .filter(name => !name.startsWith(CACHE_VERSION))
          .map(name => {
            console.log('[SW] Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    }).then(() => {
      console.log('[SW] Activated and ready');
      return self.clients.claim(); // Take control immediately
    })
  );
});

// Fetch event - serve from cache with network fallback
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Skip non-GET requests
  if (request.method !== 'GET') {
    return;
  }

  // Handle API requests
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(handleAPIRequest(request));
    return;
  }

  // Handle image requests
  if (request.destination === 'image' || /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i.test(url.pathname)) {
    event.respondWith(handleImageRequest(request));
    return;
  }

  // Handle camera/video streams (don't cache)
  if (request.destination === 'video' || url.pathname.includes('/camera')) {
    event.respondWith(fetch(request));
    return;
  }

  // Handle AR model files
  if (url.pathname.includes('/models/') || url.pathname.endsWith('.glb') || url.pathname.endsWith('.gltf')) {
    event.respondWith(handleARModelRequest(request));
    return;
  }

  // Handle static assets
  event.respondWith(handleStaticRequest(request));
});

/**
 * Handle API requests with network-first strategy
 */
async function handleAPIRequest(request) {
  try {
    const networkResponse = await fetch(request);

    // Cache successful responses
    if (networkResponse && networkResponse.ok) {
      const cache = await caches.open(API_CACHE);
      cache.put(request, networkResponse.clone());
    }

    return networkResponse;
  } catch (error) {
    // Network failed, try cache
    const cachedResponse = await caches.match(request);

    if (cachedResponse) {
      console.log('[SW] Serving API from cache (offline):', request.url);

      // Add offline indicator header
      const headers = new Headers(cachedResponse.headers);
      headers.set('X-Served-From-Cache', 'true');

      return new Response(cachedResponse.body, {
        status: cachedResponse.status,
        statusText: cachedResponse.statusText,
        headers: headers
      });
    }

    // Return offline data if available
    return new Response(
      JSON.stringify({
        error: 'Offline',
        message: 'This data is not available offline'
      }),
      {
        status: 503,
        headers: { 'Content-Type': 'application/json' }
      }
    );
  }
}

/**
 * Handle image requests with cache-first strategy
 */
async function handleImageRequest(request) {
  const cachedResponse = await caches.match(request);

  if (cachedResponse) {
    return cachedResponse;
  }

  try {
    const networkResponse = await fetch(request);

    if (networkResponse && networkResponse.ok) {
      const cache = await caches.open(IMAGE_CACHE);
      cache.put(request, networkResponse.clone());
    }

    return networkResponse;
  } catch (error) {
    // Return placeholder image for offline
    return new Response(
      '<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><rect fill="#ddd" width="400" height="400"/><text x="50%" y="50%" text-anchor="middle" fill="#999">Offline</text></svg>',
      { headers: { 'Content-Type': 'image/svg+xml' } }
    );
  }
}

/**
 * Handle AR model requests with cache-first strategy
 */
async function handleARModelRequest(request) {
  const cachedResponse = await caches.match(request);

  if (cachedResponse) {
    console.log('[SW] Serving AR model from cache:', request.url);
    return cachedResponse;
  }

  try {
    const networkResponse = await fetch(request);

    if (networkResponse && networkResponse.ok) {
      const cache = await caches.open(AR_CACHE);
      cache.put(request, networkResponse.clone());
    }

    return networkResponse;
  } catch (error) {
    console.error('[SW] AR model not available offline:', request.url);
    return new Response('AR model not available offline', { status: 503 });
  }
}

/**
 * Handle static asset requests with stale-while-revalidate
 */
async function handleStaticRequest(request) {
  const cache = await caches.open(STATIC_CACHE);
  const cachedResponse = await cache.match(request);

  const fetchPromise = fetch(request).then(networkResponse => {
    if (networkResponse && networkResponse.ok) {
      cache.put(request, networkResponse.clone());
    }
    return networkResponse;
  }).catch(() => cachedResponse);

  return cachedResponse || fetchPromise || caches.match('/offline.html');
}

/**
 * Background Sync for offline actions
 */
self.addEventListener('sync', (event) => {
  console.log('[SW] Background sync:', event.tag);

  if (event.tag === 'sync-watchlist') {
    event.waitUntil(syncWatchlist());
  }

  if (event.tag === 'sync-favorites') {
    event.waitUntil(syncFavorites());
  }

  if (event.tag === 'sync-scanned-watches') {
    event.waitUntil(syncScannedWatches());
  }
});

async function syncWatchlist() {
  try {
    const db = await openDB();
    const tx = db.transaction('pendingActions', 'readonly');
    const store = tx.objectStore('pendingActions');
    const actions = await getAllFromStore(store);

    for (const action of actions) {
      if (action.type === 'watchlist') {
        const response = await fetch('/api/watchlist', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(action.data)
        });

        if (response.ok) {
          const deleteTx = db.transaction('pendingActions', 'readwrite');
          await deleteTx.objectStore('pendingActions').delete(action.id);
        }
      }
    }

    console.log('[SW] Watchlist synced successfully');
  } catch (error) {
    console.error('[SW] Sync failed:', error);
    throw error; // Will retry sync
  }
}

async function syncFavorites() {
  // Similar implementation for favorites
  console.log('[SW] Favorites sync initiated');
}

async function syncScannedWatches() {
  try {
    const db = await openDB();
    const tx = db.transaction('scannedWatches', 'readonly');
    const store = tx.objectStore('scannedWatches');
    const scans = await getAllFromStore(store);

    for (const scan of scans) {
      if (!scan.synced) {
        const response = await fetch('/api/watches/scan', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(scan.data)
        });

        if (response.ok) {
          scan.synced = true;
          const updateTx = db.transaction('scannedWatches', 'readwrite');
          await updateTx.objectStore('scannedWatches').put(scan);
        }
      }
    }

    console.log('[SW] Scanned watches synced');
  } catch (error) {
    console.error('[SW] Scan sync failed:', error);
  }
}

/**
 * Push Notification handling
 */
self.addEventListener('push', (event) => {
  console.log('[SW] Push notification received');

  let data = {
    title: 'Omega Watch Update',
    body: 'New price alert available',
    icon: '/icons/icon-192x192.svg',
    badge: '/icons/icon-72x72.svg',
    tag: 'omega-notification',
    data: {}
  };

  if (event.data) {
    try {
      data = { ...data, ...event.data.json() };
    } catch (e) {
      data.body = event.data.text();
    }
  }

  const options = {
    body: data.body,
    icon: data.icon,
    badge: data.badge,
    vibrate: [200, 100, 200],
    tag: data.tag,
    requireInteraction: data.requireInteraction || false,
    data: data.data,
    actions: [
      { action: 'view', title: 'View Details', icon: '/icons/icon-96x96.svg' },
      { action: 'dismiss', title: 'Dismiss' }
    ],
    image: data.image
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

/**
 * Notification click handler
 */
self.addEventListener('notificationclick', (event) => {
  console.log('[SW] Notification clicked:', event.action);

  event.notification.close();

  if (event.action === 'view') {
    const urlToOpen = event.notification.data.url || '/';

    event.waitUntil(
      clients.matchAll({ type: 'window', includeUncontrolled: true })
        .then(windowClients => {
          // Check if there's already a window open
          for (let client of windowClients) {
            if (client.url === urlToOpen && 'focus' in client) {
              return client.focus();
            }
          }
          // Open new window if none exists
          if (clients.openWindow) {
            return clients.openWindow(urlToOpen);
          }
        })
    );
  }
});

/**
 * Periodic Background Sync for price updates
 */
self.addEventListener('periodicsync', (event) => {
  console.log('[SW] Periodic sync:', event.tag);

  if (event.tag === 'update-prices') {
    event.waitUntil(updatePriceData());
  }
});

async function updatePriceData() {
  try {
    const response = await fetch('/api/watches/updates');
    const data = await response.json();

    // Update cache with new data
    const cache = await caches.open(API_CACHE);
    await cache.put('/api/watches', new Response(JSON.stringify(data)));

    // Notify user of significant price changes
    if (data.significantChanges && data.significantChanges.length > 0) {
      const change = data.significantChanges[0];
      await self.registration.showNotification('Price Alert!', {
        body: `${change.model}: ${change.change > 0 ? '+' : ''}${change.change}%`,
        icon: '/icons/icon-192x192.svg',
        badge: '/icons/icon-72x72.svg',
        data: { url: `/?watch=${change.id}` }
      });
    }

    console.log('[SW] Price data updated');
  } catch (error) {
    console.error('[SW] Price update failed:', error);
  }
}

/**
 * Message handler for app communication
 */
self.addEventListener('message', (event) => {
  console.log('[SW] Message received:', event.data);

  if (event.data.action === 'skipWaiting') {
    self.skipWaiting();
  }

  if (event.data.action === 'clearCache') {
    event.waitUntil(
      caches.keys().then(cacheNames => {
        return Promise.all(cacheNames.map(name => caches.delete(name)));
      }).then(() => {
        event.ports[0].postMessage({ success: true });
      })
    );
  }

  if (event.data.action === 'cacheCamera') {
    event.waitUntil(
      caches.open(CAMERA_CACHE).then(cache => {
        return cache.put('/camera-frame', new Response(event.data.imageData));
      })
    );
  }

  if (event.data.action === 'getCacheStats') {
    event.waitUntil(
      getCacheStats().then(stats => {
        event.ports[0].postMessage(stats);
      })
    );
  }
});

/**
 * Get cache statistics
 */
async function getCacheStats() {
  const cacheNames = await caches.keys();
  const stats = {};

  for (const name of cacheNames) {
    const cache = await caches.open(name);
    const keys = await cache.keys();
    stats[name] = keys.length;
  }

  return stats;
}

/**
 * IndexedDB helper functions
 */
function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('OmegaWatchesPWA', 2);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;

      // Pending actions store
      if (!db.objectStoreNames.contains('pendingActions')) {
        db.createObjectStore('pendingActions', { keyPath: 'id', autoIncrement: true });
      }

      // Watches store
      if (!db.objectStoreNames.contains('watches')) {
        const watchStore = db.createObjectStore('watches', { keyPath: 'id' });
        watchStore.createIndex('series', 'series', { unique: false });
        watchStore.createIndex('model', 'model', { unique: false });
      }

      // Scanned watches store
      if (!db.objectStoreNames.contains('scannedWatches')) {
        const scanStore = db.createObjectStore('scannedWatches', { keyPath: 'id', autoIncrement: true });
        scanStore.createIndex('timestamp', 'timestamp', { unique: false });
        scanStore.createIndex('synced', 'synced', { unique: false });
      }

      // User preferences store
      if (!db.objectStoreNames.contains('preferences')) {
        db.createObjectStore('preferences', { keyPath: 'key' });
      }

      // Biometric data store (encrypted)
      if (!db.objectStoreNames.contains('biometric')) {
        db.createObjectStore('biometric', { keyPath: 'userId' });
      }
    };
  });
}

function getAllFromStore(store) {
  return new Promise((resolve, reject) => {
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

console.log('[SW] Service Worker v2.0.0 loaded and ready');