From 0a794583d7396feac1c056a9cc37bf5bb50519ee Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:53:12 +0200 Subject: [PATCH] fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes BLOCKER B5 — the offline map was blank for most real trips: - The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments. - prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate exceeded the cap, so region/road-trip bboxes got no tiles. Removed the all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at the budget, so large trips now cache the zooms that fit instead of nothing. --- client/src/sync/tilePrefetcher.ts | 29 +++++++++------ client/tests/unit/sync/tilePrefetcher.test.ts | 37 ++++++++++++++++--- client/vite.config.js | 8 +++- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index 086b0beb..e2fba143 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -17,11 +17,18 @@ import { offlineDb, upsertSyncMeta } from '../db/offlineDb' // ── Constants ───────────────────────────────────────────────────────────────── -/** Estimated average tile size in KB (road/transit tiles ~15 KB). */ +/** Estimated average tile size in KB (raster basemap tiles ~15 KB). */ const AVG_TILE_KB = 15 -/** Hard cap: ~50 MB worth of tiles. */ -export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413 +/** + * Hard cap on prefetched tiles (~180 MB). + * + * MUST stay in sync with the Workbox 'map-tiles' `maxEntries` in + * client/vite.config.js (kept equal). If this budget exceeds the SW cache size, + * the LRU evicts freshly-prefetched tiles on arrival and the offline map goes + * blank — which is exactly the bug this value was raised (from ~3413) to fix. + */ +export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288 const DEFAULT_TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' @@ -177,15 +184,13 @@ export async function prefetchTilesForTrip( const bbox = computeBbox(places) if (!bbox) return - // Size guard: if total tile count across all zooms exceeds cap, skip - const estimated = countTiles(bbox, 10, 16) - if (estimated > MAX_TILES) { - console.warn( - `[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`, - ) - return - } - + // Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops + // once MAX_TILES is reached, so large (region / road-trip) bboxes still get + // their lower zooms cached instead of being skipped entirely. + // + // NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each + // for quota accounting, so the real on-disk budget is far below 180 MB. That + // (audit H8) and navigator.storage.persist() (M6) are tracked separately. const fetched = await prefetchTiles(bbox, template) // Update syncMeta with bbox and tile count diff --git a/client/tests/unit/sync/tilePrefetcher.test.ts b/client/tests/unit/sync/tilePrefetcher.test.ts index f0666594..76828385 100644 --- a/client/tests/unit/sync/tilePrefetcher.test.ts +++ b/client/tests/unit/sync/tilePrefetcher.test.ts @@ -207,17 +207,42 @@ describe('prefetchTilesForTrip', () => { expect(meta!.tilesBbox).toHaveLength(4); }); - it('skips prefetch when estimated tiles exceed MAX_TILES', async () => { + it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => { await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); - // Places far apart → huge bbox → estimate > MAX_TILES + // ~4° road-trip span: low zooms fit the budget, high zooms (z14+) blow past + // it. The old guard skipped the whole trip; now we keep what fits. const places = [ - buildPlace({ trip_id: 1, lat: -60, lng: -170 }), - buildPlace({ trip_id: 1, lat: 60, lng: 170 }), + buildPlace({ trip_id: 1, lat: 45.0, lng: 0.0 }), + buildPlace({ trip_id: 1, lat: 49.0, lng: 4.0 }), ]; await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png'); - // No fetches should have been made - expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + // Previously this skipped entirely; now it prefetches a clamped subset. + const calls = vi.mocked(fetch).mock.calls.length; + expect(calls).toBeGreaterThan(0); + expect(calls).toBeLessThanOrEqual(MAX_TILES); + }); + + it('prefetches a region-sized (0.5°) trip that the old all-or-nothing guard would have skipped', async () => { + await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); + + const places = [ + buildPlace({ trip_id: 1, lat: 48.6, lng: 2.1 }), + buildPlace({ trip_id: 1, lat: 49.1, lng: 2.6 }), + ]; + await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png'); + + const calls = vi.mocked(fetch).mock.calls.length; + expect(calls).toBeGreaterThan(0); + expect(calls).toBeLessThanOrEqual(MAX_TILES); + }); +}); + +// ── cap coherence ─────────────────────────────────────────────────────────────── + +describe('MAX_TILES budget', () => { + it('matches the Workbox map-tiles maxEntries in vite.config.js (drift guard)', () => { + expect(MAX_TILES).toBe(12288); }); }); diff --git a/client/vite.config.js b/client/vite.config.js index 848a1818..ee81f09c 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -15,21 +15,25 @@ export default defineConfig({ runtimeCaching: [ { // Carto map tiles (default provider) + // maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts + // (both are 12288) so prefetched tiles aren't evicted on arrival. urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i, handler: 'CacheFirst', options: { cacheName: 'map-tiles', - expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 }, + expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 }, cacheableResponse: { statuses: [0, 200] }, }, }, { // OpenStreetMap tiles (fallback / alternative) + // Shares the 'map-tiles' cache; keep maxEntries equal to the Carto + // rule above and MAX_TILES in src/sync/tilePrefetcher.ts (12288). urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, handler: 'CacheFirst', options: { cacheName: 'map-tiles', - expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 }, + expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 }, cacheableResponse: { statuses: [0, 200] }, }, },