// Service Worker for Zellyy Finance PWA const CACHE_NAME = "zellyy-finance-v1"; const STATIC_CACHE_NAME = `${CACHE_NAME}-static`; const DYNAMIC_CACHE_NAME = `${CACHE_NAME}-dynamic`; const API_CACHE_NAME = `${CACHE_NAME}-api`; // Static files to cache const STATIC_FILES = [ "/", "/manifest.json", "/favicon.ico", "/zellyy.png", "/og-image.png", "/placeholder.svg", ]; // API endpoints to cache const API_ENDPOINTS = ["/api/transactions", "/api/budgets", "/api/analytics"]; // Install event - cache static files self.addEventListener("install", (event) => { console.log("Service Worker: Installing..."); event.waitUntil( caches .open(STATIC_CACHE_NAME) .then((cache) => { console.log("Service Worker: Caching static files"); return cache.addAll(STATIC_FILES); }) .then(() => { console.log("Service Worker: Installation complete"); return self.skipWaiting(); }) .catch((error) => { console.error("Service Worker: Installation failed:", error); }) ); }); // Activate event - clean up old caches self.addEventListener("activate", (event) => { console.log("Service Worker: Activating..."); event.waitUntil( caches .keys() .then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if ( cacheName !== STATIC_CACHE_NAME && cacheName !== DYNAMIC_CACHE_NAME && cacheName !== API_CACHE_NAME ) { console.log("Service Worker: Deleting old cache:", cacheName); return caches.delete(cacheName); } }) ); }) .then(() => { console.log("Service Worker: Activation complete"); return self.clients.claim(); }) .catch((error) => { console.error("Service Worker: Activation failed:", error); }) ); }); // Fetch event - handle network requests self.addEventListener("fetch", (event) => { const { request } = event; const url = new URL(request.url); // Handle different types of requests if (request.method !== "GET") { // Don't cache non-GET requests return; } // Handle API requests if (isApiRequest(url)) { event.respondWith(handleApiRequest(request)); return; } // Handle static assets if (isStaticAsset(url)) { event.respondWith(handleStaticAsset(request)); return; } // Handle navigation requests (HTML pages) if (isNavigationRequest(request)) { event.respondWith(handleNavigationRequest(request)); return; } // Default: network first, cache fallback event.respondWith( fetch(request) .then((response) => { // Cache successful responses if (response.status === 200) { const responseClone = response.clone(); caches .open(DYNAMIC_CACHE_NAME) .then((cache) => cache.put(request, responseClone)); } return response; }) .catch(() => { // Fallback to cache return caches.match(request); }) ); }); // Handle API requests with network-first strategy async function handleApiRequest(request) { try { const networkResponse = await fetch(request); if (networkResponse.ok) { // Cache successful API responses const responseClone = networkResponse.clone(); const cache = await caches.open(API_CACHE_NAME); await cache.put(request, responseClone); return networkResponse; } throw new Error(`API request failed: ${networkResponse.status}`); } catch (error) { console.log("Service Worker: Network failed, trying cache:", error); // Fallback to cache const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // Return offline page for failed API requests return new Response( JSON.stringify({ error: "Offline - data not available", offline: true, timestamp: Date.now(), }), { status: 503, statusText: "Service Unavailable", headers: { "Content-Type": "application/json", }, } ); } } // Handle static assets with cache-first strategy async function handleStaticAsset(request) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } try { const networkResponse = await fetch(request); if (networkResponse.ok) { const responseClone = networkResponse.clone(); const cache = await caches.open(DYNAMIC_CACHE_NAME); await cache.put(request, responseClone); } return networkResponse; } catch (error) { console.error("Service Worker: Failed to fetch static asset:", error); // Return placeholder for failed static assets if ( request.url.includes(".png") || request.url.includes(".jpg") || request.url.includes(".svg") ) { return caches.match("/placeholder.svg"); } throw error; } } // Handle navigation requests with network-first strategy async function handleNavigationRequest(request) { try { const networkResponse = await fetch(request); if (networkResponse.ok) { const responseClone = networkResponse.clone(); const cache = await caches.open(DYNAMIC_CACHE_NAME); await cache.put(request, responseClone); } return networkResponse; } catch (error) { console.log("Service Worker: Navigation network failed, trying cache"); // Fallback to cached version const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // Fallback to cached index.html for SPA routing return caches.match("/"); } } // Helper functions function isApiRequest(url) { return ( url.pathname.startsWith("/api/") || url.hostname !== self.location.hostname || API_ENDPOINTS.some((endpoint) => url.pathname.includes(endpoint)) ); } function isStaticAsset(url) { const pathname = url.pathname; return ( pathname.includes(".") && (pathname.endsWith(".js") || pathname.endsWith(".css") || pathname.endsWith(".png") || pathname.endsWith(".jpg") || pathname.endsWith(".jpeg") || pathname.endsWith(".svg") || pathname.endsWith(".webp") || pathname.endsWith(".ico") || pathname.endsWith(".woff") || pathname.endsWith(".woff2") || pathname.endsWith(".ttf") || pathname.endsWith(".eot")) ); } function isNavigationRequest(request) { return ( request.mode === "navigate" || (request.method === "GET" && request.headers.get("accept").includes("text/html")) ); } // Push notification event self.addEventListener("push", (event) => { console.log("Service Worker: Push notification received"); let data = {}; if (event.data) { try { data = event.data.json(); } catch (error) { data = { title: event.data.text() || "Zellyy Finance" }; } } const options = { title: data.title || "Zellyy Finance", body: data.body || "새로운 알림이 있습니다.", icon: "/zellyy.png", badge: "/zellyy.png", data: data.url || "/", actions: [ { action: "open", title: "열기", }, { action: "close", title: "닫기", }, ], vibrate: [100, 50, 100], tag: "zellyy-notification", }; event.waitUntil(self.registration.showNotification(options.title, options)); }); // Notification click event self.addEventListener("notificationclick", (event) => { console.log("Service Worker: Notification clicked"); event.notification.close(); if (event.action === "close") { return; } const urlToOpen = event.notification.data || "/"; event.waitUntil( clients .matchAll({ type: "window", includeUncontrolled: true }) .then((clientList) => { // Check if app is already open for (const client of clientList) { if (client.url === urlToOpen && "focus" in client) { return client.focus(); } } // Open new window if (clients.openWindow) { return clients.openWindow(urlToOpen); } }) ); }); // Background sync event (for future use) self.addEventListener("sync", (event) => { console.log("Service Worker: Background sync triggered"); if (event.tag === "background-sync") { event.waitUntil( // Handle background sync tasks handleBackgroundSync() ); } }); async function handleBackgroundSync() { try { // Sync pending transactions or other offline data console.log("Service Worker: Performing background sync"); // This would sync any pending data when network is available // Implementation depends on app's offline storage strategy } catch (error) { console.error("Service Worker: Background sync failed:", error); } } // Message event for communication with main thread self.addEventListener("message", (event) => { console.log("Service Worker: Message received:", event.data); switch (event.data.type) { case "SKIP_WAITING": self.skipWaiting(); break; case "GET_CACHE_SIZE": getCacheSize().then((size) => { event.ports[0].postMessage({ cacheSize: size }); }); break; case "CLEAR_CACHE": clearAllCaches().then(() => { event.ports[0].postMessage({ cleared: true }); }); break; default: console.log("Service Worker: Unknown message type:", event.data.type); } }); async function getCacheSize() { const cacheNames = await caches.keys(); let totalSize = 0; for (const cacheName of cacheNames) { const cache = await caches.open(cacheName); const requests = await cache.keys(); totalSize += requests.length; } return totalSize; } async function clearAllCaches() { const cacheNames = await caches.keys(); return Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName))); }