Files
TREK/client/src/sync/tilePrefetcher.ts
T
jubnl b194e8317d feat(pwa): implement real offline mode with IndexedDB sync
Add genuine offline read/write capability for trips:

- Dexie IndexedDB schema (trips, places, packing, todo, budget,
  reservations, files, mutationQueue, syncMeta, blobCache)
- Repo layer for all domains: offline reads from Dexie, writes
  optimistically to Dexie and enqueue mutations for later replay
- Mutation queue with UUID idempotency keys (X-Idempotency-Key),
  FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx
- Trip sync manager: caches all trips with end_date >= today or null,
  auto-evicts 7d after end_date, fetches bundle endpoint in one request
- Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap,
  warms SW cache via fetch
- Sync triggers: network online → flush + syncAll; WS reconnect →
  flush only (rate-limiter safe); visibilitychange/30s → flush only
- WS remoteEventHandler writes through to Dexie on every event
- Server idempotency middleware + idempotency_keys table (migration 100,
  24h TTL nightly cleanup)
- GET /api/trips/:id/bundle endpoint for efficient single-request sync
- OfflineBanner component: amber (offline) / blue (syncing) / hidden
- OfflineTab in Settings: cached trip list, re-sync and clear actions
- usePendingMutations hook for per-item pending indicators

Closes #505 #541
2026-04-14 23:04:25 +02:00

204 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Map tile prefetcher — warms the Workbox 'map-tiles' cache for a trip's
* bounding box so maps render offline.
*
* Algorithm:
* 1. Compute bbox from trip's place coordinates + padding.
* 2. For zooms 1016, enumerate tile XYZ coordinates within bbox.
* 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB).
* 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it.
*
* Tile URL template format: Leaflet-compatible {z}/{x}/{y} with optional
* {s} (subdomain) and {r} (retina suffix).
*/
import type { Place } from '../types'
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
// ── Constants ─────────────────────────────────────────────────────────────────
/** Estimated average tile size in KB (road/transit 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
const DEFAULT_TILE_URL =
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const SUBDOMAINS = ['a', 'b', 'c', 'd']
let _subIdx = 0
function nextSubdomain(): string {
return SUBDOMAINS[_subIdx++ % SUBDOMAINS.length]
}
// ── Tile math ──────────────────────────────────────────────────────────────────
/** Longitude → tile X at given zoom. */
export function lngToTileX(lng: number, zoom: number): number {
return Math.floor(((lng + 180) / 360) * Math.pow(2, zoom))
}
/** Latitude → tile Y at given zoom (Web Mercator, y increases southward). */
export function latToTileY(lat: number, zoom: number): number {
const n = Math.pow(2, zoom)
const latRad = (lat * Math.PI) / 180
return Math.floor(
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n,
)
}
/** Expand a single-point bbox to min 0.1° span (~10 km) in each axis. */
function ensureMinSpan(min: number, max: number, minSpan = 0.1): [number, number] {
if (max - min < minSpan) {
const mid = (min + max) / 2
return [mid - minSpan / 2, mid + minSpan / 2]
}
return [min, max]
}
// ── Public types ──────────────────────────────────────────────────────────────
export interface TileBbox {
minLat: number
maxLat: number
minLng: number
maxLng: number
}
// ── Core logic ────────────────────────────────────────────────────────────────
/**
* Compute the bounding box for a list of places with optional padding.
* Returns null if no places have coordinates.
*/
export function computeBbox(places: Place[], paddingFraction = 0.1): TileBbox | null {
const valid = places.filter(p => p.lat !== null && p.lng !== null)
if (valid.length === 0) return null
const lats = valid.map(p => p.lat as number)
const lngs = valid.map(p => p.lng as number)
const [rawMinLat, rawMaxLat] = ensureMinSpan(Math.min(...lats), Math.max(...lats))
const [rawMinLng, rawMaxLng] = ensureMinSpan(Math.min(...lngs), Math.max(...lngs))
const latPad = (rawMaxLat - rawMinLat) * paddingFraction
const lngPad = (rawMaxLng - rawMinLng) * paddingFraction
return {
minLat: Math.max(-85.0511, rawMinLat - latPad),
maxLat: Math.min(85.0511, rawMaxLat + latPad),
minLng: Math.max(-180, rawMinLng - lngPad),
maxLng: Math.min(180, rawMaxLng + lngPad),
}
}
/**
* Count tiles that would be fetched across the zoom range for a bbox.
* Used to enforce the size guard without actually fetching.
*/
export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): number {
let total = 0
for (let z = minZoom; z <= maxZoom; z++) {
const minX = lngToTileX(bbox.minLng, z)
const maxX = lngToTileX(bbox.maxLng, z)
const minY = latToTileY(bbox.maxLat, z) // northern edge → smaller y
const maxY = latToTileY(bbox.minLat, z) // southern edge → larger y
total += (maxX - minX + 1) * (maxY - minY + 1)
if (total > MAX_TILES) return total
}
return total
}
/**
* Build the concrete tile URL for given z/x/y from a Leaflet template.
* Rotates through subdomains (ad).
*/
export function buildTileUrl(template: string, z: number, x: number, y: number): string {
return template
.replace('{z}', String(z))
.replace('{x}', String(x))
.replace('{y}', String(y))
.replace('{s}', nextSubdomain())
.replace('{r}', '')
}
/**
* Prefetch tiles for a bbox into the Service Worker cache.
* Stops at the zoom level where the size cap would be exceeded.
* No-ops when:
* - offline
* - no active Service Worker (tiles won't be cached anyway)
* - total tile count exceeds MAX_TILES before even starting zoom 10
*/
export async function prefetchTiles(
bbox: TileBbox,
tileUrlTemplate: string,
minZoom = 10,
maxZoom = 16,
): Promise<number> {
if (!navigator.onLine) return 0
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
let fetched = 0
for (let z = minZoom; z <= maxZoom; z++) {
const minX = lngToTileX(bbox.minLng, z)
const maxX = lngToTileX(bbox.maxLng, z)
const minY = latToTileY(bbox.maxLat, z)
const maxY = latToTileY(bbox.minLat, z)
const count = (maxX - minX + 1) * (maxY - minY + 1)
if (fetched + count > MAX_TILES) break
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
const url = buildTileUrl(tileUrlTemplate, z, x, y)
// Fire-and-forget: SW CacheFirst handler stores the response
fetch(url, { mode: 'no-cors' }).catch(() => {})
fetched++
}
}
}
return fetched
}
/**
* Full pipeline: compute bbox → guard → prefetch → update syncMeta.
* Designed to be called fire-and-forget from tripSyncManager.
*/
export async function prefetchTilesForTrip(
tripId: number,
places: Place[],
tileUrlTemplate?: string,
): Promise<void> {
const template = tileUrlTemplate || DEFAULT_TILE_URL
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
}
const fetched = await prefetchTiles(bbox, template)
// Update syncMeta with bbox and tile count
const meta = await offlineDb.syncMeta.get(tripId)
if (meta) {
await upsertSyncMeta({
...meta,
tilesBbox: [bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat],
})
}
if (fetched > 0) {
console.info(`[tilePrefetch] trip ${tripId}: queued ${fetched} tiles for caching`)
}
}