/** * TASKMATE - Service Worker * ========================== * Offline support and caching */ const CACHE_VERSION = '152'; const CACHE_NAME = 'taskmate-v' + CACHE_VERSION; const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION; const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION; // Files to cache immediately const STATIC_ASSETS = [ '/', '/index.html', '/css/variables.css', '/css/base.css', '/css/components.css', '/css/board.css', '/css/modal.css', '/css/calendar.css', '/css/responsive.css', '/js/app.js', '/js/utils.js', '/js/api.js', '/js/auth.js', '/js/store.js', '/js/sync.js', '/js/offline.js', '/js/board.js', '/js/task-modal.js', '/js/calendar.js', '/js/list.js', '/js/shortcuts.js', '/js/undo.js', '/js/tour.js', '/js/admin.js', '/js/proposals.js', '/js/notifications.js', '/js/gitea.js', '/js/knowledge.js', '/css/list.css', '/css/admin.css', '/css/proposals.css', '/css/notifications.css', '/css/gitea.css', '/css/knowledge.css' ]; // API routes to cache const API_CACHE_ROUTES = [ '/api/projects', '/api/auth/users' ]; // Install event - cache static assets self.addEventListener('install', (event) => { console.log('[SW] Installing...'); event.waitUntil( caches.open(STATIC_CACHE_NAME) .then((cache) => { console.log('[SW] Caching static assets'); return cache.addAll(STATIC_ASSETS); }) .then(() => self.skipWaiting()) ); }); // Activate event - clean up old caches self.addEventListener('activate', (event) => { console.log('[SW] Activating...'); event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => { return name.startsWith('taskmate-') && name !== STATIC_CACHE_NAME && name !== DYNAMIC_CACHE_NAME; }) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }).then(() => self.clients.claim()) ); }); // Fetch event - serve from cache or network self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Skip non-GET requests if (event.request.method !== 'GET') { return; } // Skip WebSocket requests if (url.protocol === 'ws:' || url.protocol === 'wss:') { return; } // Handle API requests if (url.pathname.startsWith('/api/')) { event.respondWith(handleApiRequest(event.request)); return; } // Handle static assets event.respondWith(handleStaticRequest(event.request)); }); // Handle static asset requests - Network First strategy async function handleStaticRequest(request) { // Try network first to always get fresh content try { const networkResponse = await fetch(request); // Cache successful responses if (networkResponse.ok) { const cache = await caches.open(STATIC_CACHE_NAME); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { // If network fails, try cache const cachedResponse = await caches.match(request); if (cachedResponse) { console.log('[SW] Serving from cache (offline):', request.url); return cachedResponse; } // Return offline page if available for navigation if (request.mode === 'navigate') { const offlinePage = await caches.match('/index.html'); if (offlinePage) { return offlinePage; } } throw error; } } // Handle API requests async function handleApiRequest(request) { const url = new URL(request.url); // Check if this is a cacheable API route const isCacheable = API_CACHE_ROUTES.some(route => url.pathname.startsWith(route) ); // Try network first for API requests try { const networkResponse = await fetch(request); // Cache successful GET responses for cacheable routes if (networkResponse.ok && isCacheable) { const cache = await caches.open(DYNAMIC_CACHE_NAME); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { // If offline, try to return cached response if (isCacheable) { const cachedResponse = await caches.match(request); if (cachedResponse) { console.log('[SW] Serving cached API response:', request.url); return cachedResponse; } } // Return error response return new Response( JSON.stringify({ error: 'Keine Internetverbindung', offline: true }), { status: 503, headers: { 'Content-Type': 'application/json' } } ); } } // Background sync self.addEventListener('sync', (event) => { console.log('[SW] Background sync:', event.tag); if (event.tag === 'sync-pending') { event.waitUntil(syncPendingOperations()); } }); // Sync pending operations async function syncPendingOperations() { // This will be handled by the main app // Just notify clients that sync is needed const clients = await self.clients.matchAll(); clients.forEach(client => { client.postMessage({ type: 'SYNC_NEEDED' }); }); } // Push notifications (for future use) self.addEventListener('push', (event) => { if (!event.data) return; const data = event.data.json(); const options = { body: data.body, icon: '/icons/icon-192.png', badge: '/icons/badge-72.png', vibrate: [100, 50, 100], data: { url: data.url || '/' }, actions: [ { action: 'open', title: 'Öffnen' }, { action: 'close', title: 'Schließen' } ] }; event.waitUntil( self.registration.showNotification(data.title || 'TaskMate', options) ); }); // Notification click self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.action === 'close') { return; } const url = event.notification.data?.url || '/'; event.waitUntil( self.clients.matchAll({ type: 'window' }).then((clients) => { // Check if there's already a window open for (const client of clients) { if (client.url === url && 'focus' in client) { return client.focus(); } } // Open new window if none found if (self.clients.openWindow) { return self.clients.openWindow(url); } }) ); }); // Message handling self.addEventListener('message', (event) => { console.log('[SW] Message received:', event.data); if (event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } if (event.data.type === 'CACHE_URLS') { event.waitUntil( caches.open(DYNAMIC_CACHE_NAME).then((cache) => { return cache.addAll(event.data.urls); }) ); } if (event.data.type === 'CLEAR_CACHE') { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((name) => caches.delete(name)) ); }) ); } }); console.log('[SW] Service Worker loaded');