const TILE_CACHE = 'lageplan-tiles-v3' const STATIC_CACHE = 'lageplan-static-v3' const APP_CACHE = 'lageplan-app-v3' const API_CACHE = 'lageplan-api-v3' // API routes that should be cached for offline use const CACHEABLE_API = ['/api/icons', '/api/hose-types', '/api/dictionary'] // Pre-cache essential app shell on install self.addEventListener('install', (event) => { event.waitUntil( caches.open(APP_CACHE).then((cache) => cache.addAll([ '/app', '/login', '/', '/logo.svg', '/logo-icon.png', '/manifest.json', ]).catch(() => {}) ) ) self.skipWaiting() }) self.addEventListener('fetch', (event) => { const url = event.request.url const { pathname } = new URL(url) // Skip non-GET requests if (event.request.method !== 'GET') return // Never intercept Socket.IO — let it pass through directly if (pathname.startsWith('/socket.io')) return // Cacheable API routes: Network First with cache fallback (icons, hose-types, dictionary) if (CACHEABLE_API.some(p => pathname.startsWith(p))) { event.respondWith( caches.open(API_CACHE).then((cache) => fetch(event.request).then((response) => { if (response.ok) cache.put(event.request, response.clone()) return response }).catch(() => cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', { status: 503, headers: { 'Content-Type': 'application/json' } })) ) ) ) return } // Projects API: Network First with cache fallback if (pathname === '/api/projects' || pathname.match(/^\/api\/projects\/[^/]+$/)) { event.respondWith( caches.open(API_CACHE).then((cache) => fetch(event.request).then((response) => { if (response.ok) cache.put(event.request, response.clone()) return response }).catch(() => cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', { status: 503, headers: { 'Content-Type': 'application/json' } })) ) ) ) return } // Features API: Network First with cache fallback if (pathname.match(/^\/api\/projects\/[^/]+\/features$/)) { event.respondWith( caches.open(API_CACHE).then((cache) => fetch(event.request).then((response) => { if (response.ok) cache.put(event.request, response.clone()) return response }).catch(() => cache.match(event.request).then((cached) => cached || new Response('{"error":"offline"}', { status: 503, headers: { 'Content-Type': 'application/json' } })) ) ) ) return } // Other API requests: network only if (pathname.startsWith('/api/')) return // Cache map tiles from OSM / MapTiler / ArcGIS / Swisstopo (Cache First — tiles don't change) if (url.includes('tile.openstreetmap.org') || url.includes('api.maptiler.com') || url.includes('server.arcgisonline.com') || url.includes('geo.admin.ch')) { event.respondWith( caches.open(TILE_CACHE).then((cache) => cache.match(event.request).then((cached) => { if (cached) return cached return fetch(event.request).then((response) => { if (response.ok) cache.put(event.request, response.clone()) return response }).catch(() => new Response('', { status: 503 })) }) ) ) return } // Next.js build chunks (_next/static): Cache First (hashed filenames = immutable) if (pathname.startsWith('/_next/static/')) { event.respondWith( caches.open(STATIC_CACHE).then((cache) => cache.match(event.request).then((cached) => { if (cached) return cached return fetch(event.request).then((response) => { if (response.ok) cache.put(event.request, response.clone()) return response }).catch(() => new Response('', { status: 503 })) }) ) ) return } // Other static assets (JS, CSS, images, fonts): Stale While Revalidate if (pathname.match(/\.(js|css|png|jpg|jpeg|svg|ico|woff2?)$/)) { event.respondWith( caches.open(STATIC_CACHE).then((cache) => cache.match(event.request).then((cached) => { const fetchPromise = fetch(event.request).then((response) => { if (response.ok) cache.put(event.request, response.clone()) return response }).catch(() => cached || new Response('', { status: 503 })) return cached || fetchPromise }) ) ) return } // App pages / navigation: Network First with cache fallback if (event.request.mode === 'navigate' || pathname === '/app' || pathname === '/' || pathname.startsWith('/app')) { event.respondWith( fetch(event.request).then((response) => { if (response.ok) { const clone = response.clone() caches.open(APP_CACHE).then((cache) => cache.put(event.request, clone)) } return response }).catch(() => caches.match(event.request).then((cached) => cached || caches.match('/app')) ) ) return } }) // Clean old caches on activation self.addEventListener('activate', (event) => { const currentCaches = [TILE_CACHE, STATIC_CACHE, APP_CACHE, API_CACHE] event.waitUntil( caches.keys().then((keys) => Promise.all( keys .filter((k) => !currentCaches.includes(k)) .map((k) => caches.delete(k)) ) ).then(() => self.clients.claim()) ) }) // Listen for sync events (Background Sync for queued saves) self.addEventListener('sync', (event) => { if (event.tag === 'sync-saves') { event.waitUntil(syncQueuedSaves()) } }) // Process queued saves from IndexedDB/localStorage async function syncQueuedSaves() { try { const clients = await self.clients.matchAll() clients.forEach(client => client.postMessage({ type: 'SYNC_START' })) // Read queue from a BroadcastChannel or let the main thread handle it clients.forEach(client => client.postMessage({ type: 'FLUSH_SYNC_QUEUE' })) } catch (e) { console.error('[SW] Sync error:', e) } }