diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..1ceeee5
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,64 @@
+{
+ "name": "LUPMIS2 Drawing Tools",
+ "short_name": "LUPMIS",
+ "description": "Map and GIS functions for Land Use Planning in Ghana",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#f5f5f5",
+ "theme_color": "#6CCB2D",
+ "orientation": "any",
+ "icons": [
+ {
+ "src": "/icons/icon-72.png",
+ "sizes": "72x72",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-128.png",
+ "sizes": "128x128",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-152.png",
+ "sizes": "152x152",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/icon-384.png",
+ "sizes": "384x384",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ],
+ "categories": ["productivity", "utilities"],
+ "lang": "en",
+ "dir": "ltr"
+}
diff --git a/offline.html b/offline.html
new file mode 100644
index 0000000..8d87b7a
--- /dev/null
+++ b/offline.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+ Offline - LUPMIS
+
+
+
+
+
📴
+
You're Offline
+
+ This page isn't available offline. Please check your internet connection and try again.
+
+
+
+
+ 💡 Tip: Visit the main app while online first.
+ Once cached, you can collect data offline!
+
+
+
+
+
+
diff --git a/sw.js b/sw.js
new file mode 100644
index 0000000..257c9a9
--- /dev/null
+++ b/sw.js
@@ -0,0 +1,321 @@
+/**
+ * Service Worker
+ *
+ * Handles caching of:
+ * - App shell (HTML, CSS, JS)
+ * - Map tiles (runtime caching)
+ * - API responses (network-first)
+ *
+ * Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
+ * NOT by this service worker. They serve different purposes:
+ * - Service Worker: Caching, offline asset serving, push notifications
+ * - SharedWorker: Shared database connection across tabs
+ */
+
+const CACHE_VERSION = 'v1';
+const SHELL_CACHE = `shell-${CACHE_VERSION}`;
+const TILES_CACHE = `tiles-${CACHE_VERSION}`;
+const MODULES_CACHE = `modules-${CACHE_VERSION}`;
+const API_CACHE = `api-${CACHE_VERSION}`;
+
+// Maximum number of tiles to cache
+const MAX_TILES = 500;
+
+// App shell assets - precached on install
+// Vite will generate hashed filenames, so we cache the entry points
+// and let the browser handle the hashed assets
+const SHELL_ASSETS = [
+ '/',
+ '/index.html',
+ '/offline.html',
+ '/manifest.json'
+];
+
+// ============================================================================
+// INSTALL EVENT
+// ============================================================================
+
+self.addEventListener('install', (event) => {
+ console.log('[SW] Installing...');
+
+ event.waitUntil(
+ caches.open(SHELL_CACHE)
+ .then((cache) => {
+ console.log('[SW] Precaching app shell');
+ return cache.addAll(SHELL_ASSETS);
+ })
+ .then(() => self.skipWaiting())
+ );
+});
+
+// ============================================================================
+// ACTIVATE EVENT
+// ============================================================================
+
+self.addEventListener('activate', (event) => {
+ console.log('[SW] Activating...');
+
+ event.waitUntil(
+ caches.keys()
+ .then((cacheNames) => {
+ return Promise.all(
+ cacheNames
+ .filter((name) => {
+ // Delete old version caches
+ return (name.startsWith('shell-') && name !== SHELL_CACHE) ||
+ (name.startsWith('tiles-') && name !== TILES_CACHE) ||
+ (name.startsWith('modules-') && name !== MODULES_CACHE) ||
+ (name.startsWith('api-') && name !== API_CACHE);
+ })
+ .map((name) => {
+ console.log('[SW] Deleting old cache:', name);
+ return caches.delete(name);
+ })
+ );
+ })
+ .then(() => self.clients.claim())
+ );
+});
+
+// ============================================================================
+// FETCH EVENT
+// ============================================================================
+
+self.addEventListener('fetch', (event) => {
+ const request = event.request;
+ const url = new URL(request.url);
+
+ // Only handle GET requests
+ if (request.method !== 'GET') return;
+
+ // Skip chrome-extension and other non-http(s) requests
+ if (!url.protocol.startsWith('http')) return;
+
+ // Route to appropriate caching strategy
+ if (isMapTile(url)) {
+ event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES));
+ } else if (isApiRequest(url)) {
+ event.respondWith(networkFirst(request, API_CACHE));
+ } else if (isModuleAsset(url)) {
+ event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
+ } else if (isAppAsset(url)) {
+ event.respondWith(cacheFirst(request, SHELL_CACHE));
+ }
+ // Let other requests pass through to network
+});
+
+// ============================================================================
+// URL CLASSIFICATION
+// ============================================================================
+
+function isMapTile(url) {
+ // Common tile server patterns for all our base maps
+ return url.hostname.includes('tile.openstreetmap.org') ||
+ url.hostname.includes('opentopomap.org') ||
+ url.hostname.includes('arcgisonline.com') ||
+ url.hostname.includes('basemaps.cartocdn.com') ||
+ url.hostname.includes('tiles.') ||
+ url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) ||
+ url.pathname.match(/\/tile\/\d+\/\d+\/\d+/);
+}
+
+function isApiRequest(url) {
+ return url.pathname.startsWith('/api/') ||
+ url.pathname.endsWith('.php');
+}
+
+function isModuleAsset(url) {
+ return url.pathname.startsWith('/modules/');
+}
+
+function isAppAsset(url) {
+ // Same origin, common asset extensions
+ return url.origin === self.location.origin &&
+ (url.pathname.endsWith('.html') ||
+ url.pathname.endsWith('.css') ||
+ url.pathname.endsWith('.js') ||
+ url.pathname.endsWith('.wasm') ||
+ url.pathname.endsWith('.json') ||
+ url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp)$/));
+}
+
+// ============================================================================
+// CACHING STRATEGIES
+// ============================================================================
+
+/**
+ * Cache First - Use cache, fallback to network
+ * Best for: App shell, static assets
+ */
+async function cacheFirst(request, cacheName) {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(cacheName);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch (error) {
+ // Return offline page for navigation requests
+ if (request.mode === 'navigate') {
+ return caches.match('/offline.html');
+ }
+ throw error;
+ }
+}
+
+/**
+ * Network First - Try network, fallback to cache
+ * Best for: API requests, dynamic content
+ */
+async function networkFirst(request, cacheName) {
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(cacheName);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch (error) {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+ throw error;
+ }
+}
+
+/**
+ * Stale While Revalidate - Return cache immediately, update in background
+ * Best for: Module assets, frequently updated content
+ */
+async function staleWhileRevalidate(request, cacheName) {
+ const cache = await caches.open(cacheName);
+ const cached = await cache.match(request);
+
+ const fetchPromise = fetch(request).then((response) => {
+ if (response.ok) {
+ cache.put(request, response.clone());
+ }
+ return response;
+ }).catch(() => cached);
+
+ return cached || fetchPromise;
+}
+
+/**
+ * Cache Then Network with limit - Cache tiles with size limit
+ * Best for: Map tiles
+ */
+async function cacheThenNetwork(request, cacheName, maxItems) {
+ const cache = await caches.open(cacheName);
+ const cached = await cache.match(request);
+
+ if (cached) return cached;
+
+ try {
+ const response = await fetch(request);
+
+ if (response.ok) {
+ // Check cache size and trim if needed
+ const keys = await cache.keys();
+ if (keys.length >= maxItems) {
+ // Remove oldest entries (first 10%)
+ const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1));
+ await Promise.all(toDelete.map(key => cache.delete(key)));
+ }
+
+ cache.put(request, response.clone());
+ }
+
+ return response;
+ } catch (error) {
+ // For tiles, just fail silently - map will show blank tile
+ return new Response('', { status: 408, statusText: 'Offline' });
+ }
+}
+
+// ============================================================================
+// MESSAGE HANDLING
+// ============================================================================
+
+self.addEventListener('message', (event) => {
+ const { type, payload } = event.data || {};
+
+ switch (type) {
+ case 'SKIP_WAITING':
+ self.skipWaiting();
+ break;
+
+ case 'CACHE_MODULES':
+ cacheModules(payload.modules);
+ break;
+
+ case 'CLEAR_USER_CACHE':
+ clearUserCaches();
+ break;
+
+ case 'GET_CACHE_STATUS':
+ getCacheStatus().then(status => {
+ event.source.postMessage({ type: 'CACHE_STATUS', status });
+ });
+ break;
+ }
+});
+
+/**
+ * Cache specific modules on demand
+ */
+async function cacheModules(moduleNames) {
+ const cache = await caches.open(MODULES_CACHE);
+
+ for (const moduleName of moduleNames) {
+ try {
+ const moduleAssets = [
+ `/modules/${moduleName}/index.js`,
+ `/modules/${moduleName}/index.css`,
+ `/modules/${moduleName}/index.html`
+ ];
+
+ await cache.addAll(moduleAssets.filter(async (url) => {
+ // Only cache assets that exist
+ try {
+ const response = await fetch(url, { method: 'HEAD' });
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }));
+
+ console.log('[SW] Cached module:', moduleName);
+ } catch (error) {
+ console.warn('[SW] Failed to cache module:', moduleName, error);
+ }
+ }
+}
+
+/**
+ * Clear user-specific caches (call on logout)
+ */
+async function clearUserCaches() {
+ await caches.delete(API_CACHE);
+ await caches.delete(MODULES_CACHE);
+ console.log('[SW] Cleared user caches');
+}
+
+/**
+ * Get cache status information
+ */
+async function getCacheStatus() {
+ const cacheNames = await caches.keys();
+ const status = {};
+
+ for (const name of cacheNames) {
+ const cache = await caches.open(name);
+ const keys = await cache.keys();
+ status[name] = keys.length;
+ }
+
+ return status;
+}