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
This commit is contained in:
jubnl
2026-04-14 23:04:13 +02:00
parent 8c7567faf3
commit b194e8317d
64 changed files with 3837 additions and 638 deletions
+203
View File
@@ -0,0 +1,203 @@
/**
* 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`)
}
}