diff --git a/dist/sw.js b/dist/sw.js index 2ea9334..a3df25a 100644 --- a/dist/sw.js +++ b/dist/sw.js @@ -137,7 +137,13 @@ // Mercator metres, which overstated them by 1/cos(latitude) — about 3 m on // a 200 m circle at Wa. The Circle and Area analysis popups can be dragged // by their title bar, staying attached to the shape they describe. -const CACHE_VERSION = 'v24'; +// v25: Releases no longer delete offline map tiles. Tile cache names used to +// include CACHE_VERSION, and activation deletes caches whose names are not +// current, so every release since the tile caches were split per provider +// threw away every tile officers had downloaded for field work. Tile caches +// now carry their own TILE_CACHE_VERSION, frozen at v24, and tiles in any +// older tile cache are moved into the current ones instead of deleted. +const CACHE_VERSION = 'v25'; const SHELL_CACHE = `shell-${CACHE_VERSION}`; const MODULES_CACHE = `modules-${CACHE_VERSION}`; const API_CACHE = `api-${CACHE_VERSION}`; @@ -147,11 +153,24 @@ const API_CACHE = `api-${CACHE_VERSION}`; // Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile, // which covers a Ghana district at zoom 10–15 (typical field-work range). // ---------------------------------------------------------------------------- -const TILES_OSM = `tiles-osm-${CACHE_VERSION}`; -const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`; -const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`; -const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`; -const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`; +// +// Tile caches are versioned separately from the app. They used to take their +// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache +// whose name is not current — so each release silently threw away every tile an +// officer had downloaded for offline field work, for every provider. Map tiles +// do not change between releases of the app, so their cache names no longer do. +// +// TILE_CACHE_VERSION is frozen at the value CACHE_VERSION had when that stopped, +// so devices already on v24 keep their existing tile caches with nothing to copy. +// Change it only if the way tiles are stored changes — and even then, activate +// moves tiles from older tile caches into the new ones rather than deleting them +// (see migrateTileCache). +const TILE_CACHE_VERSION = 'v24'; +const TILES_OSM = `tiles-osm-${TILE_CACHE_VERSION}`; +const TILES_TOPO = `tiles-topo-${TILE_CACHE_VERSION}`; +const TILES_SATELLITE = `tiles-satellite-${TILE_CACHE_VERSION}`; +const TILES_CARTO_LIGHT = `tiles-carto-light-${TILE_CACHE_VERSION}`; +const TILES_CARTO_DARK = `tiles-carto-dark-${TILE_CACHE_VERSION}`; // Per-provider tile limits. // • OSM and Topographic are the providers offered for active offline @@ -228,36 +247,89 @@ self.addEventListener('install', (event) => { self.addEventListener('activate', (event) => { console.log('[SW] Activating...'); - event.waitUntil( - caches.keys() - .then((cacheNames) => { - // Build the set of caches that should remain - const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]); + event.waitUntil((async () => { + const cacheNames = await caches.keys(); + const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]); - return Promise.all( - cacheNames - // Delete anything that: - // • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-) - // • but is NOT in the current keep set - // This includes the legacy "tiles-v1" single bucket. - .filter((name) => { - const isOurs = - name.startsWith('shell-') || - name.startsWith('tiles-') || - name.startsWith('modules-') || - name.startsWith('api-'); - return isOurs && !keep.has(name); - }) - .map((name) => { - console.log('[SW] Deleting old cache:', name); - return caches.delete(name); - }) - ); - }) - .then(() => self.clients.claim()) - ); + // App caches from earlier releases hold nothing worth keeping: delete them. + await Promise.all( + cacheNames + .filter((name) => + (name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-')) + && !keep.has(name)) + .map((name) => { + console.log('[SW] Deleting old cache:', name); + return caches.delete(name); + }) + ); + + // Take control before moving tiles, so the page is not held waiting on a + // copy that can run to thousands of entries. + await self.clients.claim(); + + // Tile caches from earlier releases hold what officers downloaded for + // offline use: move those tiles into the current caches, then delete. This + // includes the legacy single "tiles-v1" bucket, whose tiles are sorted into + // the right provider by URL. + const oldTileCaches = cacheNames.filter((name) => name.startsWith('tiles-') && !keep.has(name)); + for (const name of oldTileCaches) { + await migrateTileCache(name); + } + })()); }); +/** + * Move the tiles in a tile cache from an earlier release into the current + * per-provider caches, then delete it. + * + * Each tile goes where getTileCacheName() would put it today, so the legacy + * single bucket is sorted by provider too. Nothing already in the current cache + * is overwritten, and no cache is filled past its limit. If the copy fails part + * way, the old cache is left in place rather than deleted, so its tiles are not + * lost and the next activation carries on from there. + */ +async function migrateTileCache(oldName) { + try { + const oldCache = await caches.open(oldName); + const requests = await oldCache.keys(); + const targets = new Map(); // cacheName -> { cache, count } + let moved = 0; + let skipped = 0; + + for (const request of requests) { + const url = new URL(request.url); + const targetName = getTileCacheName(url); + + // A provider no longer cached, or a Carto tile requested without a key. + // The app has sent the CARTO key on every tile request since August 2026, + // so a keyless Carto URL is never requested again — and anything cached + // after CARTO began enforcing keys carries its "API KEY REQUIRED" stamp. + const deadCarto = url.hostname.endsWith('basemaps.cartocdn.com') && !url.searchParams.has('key'); + if (!targetName || deadCarto) { skipped++; continue; } + + let target = targets.get(targetName); + if (!target) { + const cache = await caches.open(targetName); + target = { cache, count: (await cache.keys()).length }; + targets.set(targetName, target); + } + if (target.count >= (TILE_LIMITS[targetName] || 1500)) { skipped++; continue; } + if (await target.cache.match(request)) { skipped++; continue; } + + const response = await oldCache.match(request); + if (!response) { skipped++; continue; } + await target.cache.put(request, response); + target.count++; + moved++; + } + + await caches.delete(oldName); + console.log(`[SW] Moved ${moved} tiles out of ${oldName} (${skipped} not needed) and removed it`); + } catch (err) { + console.warn(`[SW] Could not move tiles out of ${oldName}; kept it for the next activation:`, err); + } +} + // ============================================================================ // FETCH EVENT // ============================================================================ diff --git a/public/sw.js b/public/sw.js index 2ea9334..a3df25a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -137,7 +137,13 @@ // Mercator metres, which overstated them by 1/cos(latitude) — about 3 m on // a 200 m circle at Wa. The Circle and Area analysis popups can be dragged // by their title bar, staying attached to the shape they describe. -const CACHE_VERSION = 'v24'; +// v25: Releases no longer delete offline map tiles. Tile cache names used to +// include CACHE_VERSION, and activation deletes caches whose names are not +// current, so every release since the tile caches were split per provider +// threw away every tile officers had downloaded for field work. Tile caches +// now carry their own TILE_CACHE_VERSION, frozen at v24, and tiles in any +// older tile cache are moved into the current ones instead of deleted. +const CACHE_VERSION = 'v25'; const SHELL_CACHE = `shell-${CACHE_VERSION}`; const MODULES_CACHE = `modules-${CACHE_VERSION}`; const API_CACHE = `api-${CACHE_VERSION}`; @@ -147,11 +153,24 @@ const API_CACHE = `api-${CACHE_VERSION}`; // Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile, // which covers a Ghana district at zoom 10–15 (typical field-work range). // ---------------------------------------------------------------------------- -const TILES_OSM = `tiles-osm-${CACHE_VERSION}`; -const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`; -const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`; -const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`; -const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`; +// +// Tile caches are versioned separately from the app. They used to take their +// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache +// whose name is not current — so each release silently threw away every tile an +// officer had downloaded for offline field work, for every provider. Map tiles +// do not change between releases of the app, so their cache names no longer do. +// +// TILE_CACHE_VERSION is frozen at the value CACHE_VERSION had when that stopped, +// so devices already on v24 keep their existing tile caches with nothing to copy. +// Change it only if the way tiles are stored changes — and even then, activate +// moves tiles from older tile caches into the new ones rather than deleting them +// (see migrateTileCache). +const TILE_CACHE_VERSION = 'v24'; +const TILES_OSM = `tiles-osm-${TILE_CACHE_VERSION}`; +const TILES_TOPO = `tiles-topo-${TILE_CACHE_VERSION}`; +const TILES_SATELLITE = `tiles-satellite-${TILE_CACHE_VERSION}`; +const TILES_CARTO_LIGHT = `tiles-carto-light-${TILE_CACHE_VERSION}`; +const TILES_CARTO_DARK = `tiles-carto-dark-${TILE_CACHE_VERSION}`; // Per-provider tile limits. // • OSM and Topographic are the providers offered for active offline @@ -228,36 +247,89 @@ self.addEventListener('install', (event) => { self.addEventListener('activate', (event) => { console.log('[SW] Activating...'); - event.waitUntil( - caches.keys() - .then((cacheNames) => { - // Build the set of caches that should remain - const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]); + event.waitUntil((async () => { + const cacheNames = await caches.keys(); + const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]); - return Promise.all( - cacheNames - // Delete anything that: - // • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-) - // • but is NOT in the current keep set - // This includes the legacy "tiles-v1" single bucket. - .filter((name) => { - const isOurs = - name.startsWith('shell-') || - name.startsWith('tiles-') || - name.startsWith('modules-') || - name.startsWith('api-'); - return isOurs && !keep.has(name); - }) - .map((name) => { - console.log('[SW] Deleting old cache:', name); - return caches.delete(name); - }) - ); - }) - .then(() => self.clients.claim()) - ); + // App caches from earlier releases hold nothing worth keeping: delete them. + await Promise.all( + cacheNames + .filter((name) => + (name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-')) + && !keep.has(name)) + .map((name) => { + console.log('[SW] Deleting old cache:', name); + return caches.delete(name); + }) + ); + + // Take control before moving tiles, so the page is not held waiting on a + // copy that can run to thousands of entries. + await self.clients.claim(); + + // Tile caches from earlier releases hold what officers downloaded for + // offline use: move those tiles into the current caches, then delete. This + // includes the legacy single "tiles-v1" bucket, whose tiles are sorted into + // the right provider by URL. + const oldTileCaches = cacheNames.filter((name) => name.startsWith('tiles-') && !keep.has(name)); + for (const name of oldTileCaches) { + await migrateTileCache(name); + } + })()); }); +/** + * Move the tiles in a tile cache from an earlier release into the current + * per-provider caches, then delete it. + * + * Each tile goes where getTileCacheName() would put it today, so the legacy + * single bucket is sorted by provider too. Nothing already in the current cache + * is overwritten, and no cache is filled past its limit. If the copy fails part + * way, the old cache is left in place rather than deleted, so its tiles are not + * lost and the next activation carries on from there. + */ +async function migrateTileCache(oldName) { + try { + const oldCache = await caches.open(oldName); + const requests = await oldCache.keys(); + const targets = new Map(); // cacheName -> { cache, count } + let moved = 0; + let skipped = 0; + + for (const request of requests) { + const url = new URL(request.url); + const targetName = getTileCacheName(url); + + // A provider no longer cached, or a Carto tile requested without a key. + // The app has sent the CARTO key on every tile request since August 2026, + // so a keyless Carto URL is never requested again — and anything cached + // after CARTO began enforcing keys carries its "API KEY REQUIRED" stamp. + const deadCarto = url.hostname.endsWith('basemaps.cartocdn.com') && !url.searchParams.has('key'); + if (!targetName || deadCarto) { skipped++; continue; } + + let target = targets.get(targetName); + if (!target) { + const cache = await caches.open(targetName); + target = { cache, count: (await cache.keys()).length }; + targets.set(targetName, target); + } + if (target.count >= (TILE_LIMITS[targetName] || 1500)) { skipped++; continue; } + if (await target.cache.match(request)) { skipped++; continue; } + + const response = await oldCache.match(request); + if (!response) { skipped++; continue; } + await target.cache.put(request, response); + target.count++; + moved++; + } + + await caches.delete(oldName); + console.log(`[SW] Moved ${moved} tiles out of ${oldName} (${skipped} not needed) and removed it`); + } catch (err) { + console.warn(`[SW] Could not move tiles out of ${oldName}; kept it for the next activation:`, err); + } +} + // ============================================================================ // FETCH EVENT // ============================================================================