mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
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:
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Mutation queue — offline write queue backed by IndexedDB (Dexie).
|
||||
*
|
||||
* Flow:
|
||||
* offline create/update/delete → enqueue() → optimistic Dexie write (in repo)
|
||||
* online trigger → flush() → replay REST with X-Idempotency-Key header → update Dexie
|
||||
*/
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { apiClient } from '../api/client'
|
||||
import type { QueuedMutation } from '../db/offlineDb'
|
||||
import type { Table } from 'dexie'
|
||||
|
||||
// Map Dexie table names used in `resource` field → actual Dexie tables.
|
||||
function getTable(resource: string): Table | undefined {
|
||||
const map: Record<string, Table> = {
|
||||
places: offlineDb.places,
|
||||
packingItems: offlineDb.packingItems,
|
||||
todoItems: offlineDb.todoItems,
|
||||
budgetItems: offlineDb.budgetItems,
|
||||
reservations: offlineDb.reservations,
|
||||
tripFiles: offlineDb.tripFiles,
|
||||
}
|
||||
return map[resource]
|
||||
}
|
||||
|
||||
/** Generate a v4-style UUID using the platform crypto API. */
|
||||
export function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
// Fallback for environments without crypto.randomUUID (e.g. old Node)
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
let _flushing = false
|
||||
|
||||
export const mutationQueue = {
|
||||
/**
|
||||
* Add a mutation to the queue.
|
||||
* Returns the UUID (= idempotency key).
|
||||
*/
|
||||
async enqueue(
|
||||
mutation: Omit<QueuedMutation, 'status' | 'attempts' | 'createdAt' | 'lastError'>,
|
||||
): Promise<string> {
|
||||
const item: QueuedMutation = {
|
||||
...mutation,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
createdAt: Date.now(),
|
||||
lastError: null,
|
||||
}
|
||||
await offlineDb.mutationQueue.put(item)
|
||||
return item.id
|
||||
},
|
||||
|
||||
/**
|
||||
* Drain the queue: replay each pending mutation against the server in FIFO order.
|
||||
* Stops on first network error (will retry on next trigger).
|
||||
* 4xx responses are marked failed and skipped.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (_flushing || !navigator.onLine) return
|
||||
_flushing = true
|
||||
try {
|
||||
const pending = await offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.equals('pending')
|
||||
.sortBy('createdAt')
|
||||
|
||||
for (const mutation of pending) {
|
||||
// Mark as syncing so UI can show progress
|
||||
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
|
||||
|
||||
try {
|
||||
const response = await apiClient.request({
|
||||
method: mutation.method,
|
||||
url: mutation.url,
|
||||
data: mutation.body,
|
||||
headers: { 'X-Idempotency-Key': mutation.id },
|
||||
})
|
||||
|
||||
// Apply canonical server response to Dexie
|
||||
if (mutation.method !== 'DELETE' && mutation.resource) {
|
||||
const table = getTable(mutation.resource)
|
||||
if (table && response.data && typeof response.data === 'object') {
|
||||
// Server returns { place: {...} } or { item: {...} } — grab first value
|
||||
const values = Object.values(response.data as Record<string, unknown>)
|
||||
const entity = values[0]
|
||||
if (entity && typeof entity === 'object' && 'id' in entity) {
|
||||
// Remove temp optimistic entry if id changed (CREATE case)
|
||||
if (mutation.tempId !== undefined && mutation.tempId !== (entity as { id: number }).id) {
|
||||
await table.delete(mutation.tempId)
|
||||
}
|
||||
await table.put(entity)
|
||||
}
|
||||
}
|
||||
} else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) {
|
||||
// DELETE was already applied optimistically; ensure it's gone
|
||||
const table = getTable(mutation.resource)
|
||||
if (table) await table.delete(mutation.entityId)
|
||||
}
|
||||
|
||||
await offlineDb.mutationQueue.delete(mutation.id)
|
||||
} catch (err: unknown) {
|
||||
const httpStatus = (err as { response?: { status: number } })?.response?.status
|
||||
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
|
||||
// Permanent client error — mark failed, continue with next
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'failed',
|
||||
attempts: mutation.attempts + 1,
|
||||
lastError: String(err),
|
||||
})
|
||||
} else {
|
||||
// Network error — reset to pending, abort flush (retry on next trigger)
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'pending',
|
||||
attempts: mutation.attempts + 1,
|
||||
lastError: String(err),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_flushing = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return all pending/syncing mutations, optionally filtered by tripId.
|
||||
* Used by the UI to show per-item pending indicators.
|
||||
*/
|
||||
async pending(tripId?: number): Promise<QueuedMutation[]> {
|
||||
if (tripId !== undefined) {
|
||||
return offlineDb.mutationQueue
|
||||
.where('tripId')
|
||||
.equals(tripId)
|
||||
.filter(m => m.status === 'pending' || m.status === 'syncing')
|
||||
.toArray()
|
||||
}
|
||||
return offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.anyOf(['pending', 'syncing'])
|
||||
.toArray()
|
||||
},
|
||||
|
||||
/** Count pending mutations (for banner badge). */
|
||||
async pendingCount(): Promise<number> {
|
||||
return offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.anyOf(['pending', 'syncing'])
|
||||
.count()
|
||||
},
|
||||
|
||||
/** Reset internal flushing flag — useful in tests. */
|
||||
_resetFlushing(): void {
|
||||
_flushing = false
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Sync triggers — register event listeners that flush the mutation queue
|
||||
* and/or run a full trip sync based on the connectivity trigger source.
|
||||
*
|
||||
* Trigger matrix:
|
||||
* window 'online' → flush mutations + full syncAll (network truly back)
|
||||
* visibilitychange visible → flush mutations only (avoid hammering server on tab switch)
|
||||
* periodic 30s → flush mutations only
|
||||
* WS reconnect → flush mutations only (no syncAll — avoids rate-limiter
|
||||
* on server restart / socket timeout while already online)
|
||||
*
|
||||
* Call `registerSyncTriggers()` once on app mount.
|
||||
* Call `unregisterSyncTriggers()` on unmount / logout.
|
||||
*/
|
||||
import { mutationQueue } from './mutationQueue'
|
||||
import { tripSyncManager } from './tripSyncManager'
|
||||
import { setPreReconnectHook } from '../api/websocket'
|
||||
|
||||
const PERIODIC_MS = 30_000
|
||||
|
||||
let _intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let _registered = false
|
||||
|
||||
/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
|
||||
function onOnline() {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
}
|
||||
|
||||
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
||||
function onVisibility() {
|
||||
if (!document.hidden && navigator.onLine) {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodic heartbeat — drain any lingering pending mutations. */
|
||||
function onPeriodic() {
|
||||
if (navigator.onLine) {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSyncTriggers(): void {
|
||||
if (_registered) return
|
||||
_registered = true
|
||||
|
||||
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate
|
||||
// limiters when the socket drops and reconnects while the device is online.
|
||||
setPreReconnectHook(() => mutationQueue.flush())
|
||||
|
||||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
_intervalId = setInterval(onPeriodic, PERIODIC_MS)
|
||||
}
|
||||
|
||||
export function unregisterSyncTriggers(): void {
|
||||
if (!_registered) return
|
||||
_registered = false
|
||||
|
||||
setPreReconnectHook(null)
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
if (_intervalId !== null) {
|
||||
clearInterval(_intervalId)
|
||||
_intervalId = null
|
||||
}
|
||||
}
|
||||
@@ -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 10–16, 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 (a–d).
|
||||
*/
|
||||
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`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Trip sync manager — seeds Dexie with trip data for offline use.
|
||||
*
|
||||
* Cache scope: trips where end_date >= today OR end_date is null/empty.
|
||||
* Eviction: trips where end_date < today - 7 days.
|
||||
* File blobs: all non-photo files (MIME type != image/*) for cached trips.
|
||||
*
|
||||
* Call syncAll() on:
|
||||
* - login success
|
||||
* - trip list refresh (DashboardPage)
|
||||
* - WS reconnect (phase 7)
|
||||
*/
|
||||
import { tripsApi } from '../api/client'
|
||||
import {
|
||||
offlineDb,
|
||||
upsertTrip,
|
||||
upsertDays,
|
||||
upsertPlaces,
|
||||
upsertPackingItems,
|
||||
upsertTodoItems,
|
||||
upsertBudgetItems,
|
||||
upsertReservations,
|
||||
upsertTripFiles,
|
||||
upsertSyncMeta,
|
||||
clearTripData,
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TripBundle {
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
packingItems: PackingItem[]
|
||||
todoItems: TodoItem[]
|
||||
budgetItems: BudgetItem[]
|
||||
reservations: Reservation[]
|
||||
files: TripFile[]
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function todayStr(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function shouldCache(trip: Trip): boolean {
|
||||
if (!trip.end_date) return true // no end date → cache forever
|
||||
return trip.end_date >= todayStr() // ongoing or future
|
||||
}
|
||||
|
||||
function isStale(trip: Trip): boolean {
|
||||
if (!trip.end_date) return false
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - 7)
|
||||
return trip.end_date < cutoff.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function isPhoto(file: TripFile): boolean {
|
||||
return file.mime_type.startsWith('image/')
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch bundle + write all entities for one trip into Dexie. */
|
||||
async function syncTrip(tripId: number): Promise<void> {
|
||||
const bundle = await tripsApi.bundle(tripId) as TripBundle
|
||||
|
||||
await upsertTrip(bundle.trip)
|
||||
await upsertDays(bundle.days)
|
||||
await upsertPlaces(bundle.places)
|
||||
await upsertPackingItems(bundle.packingItems)
|
||||
await upsertTodoItems(bundle.todoItems)
|
||||
await upsertBudgetItems(bundle.budgetItems)
|
||||
await upsertReservations(bundle.reservations)
|
||||
await upsertTripFiles(bundle.files)
|
||||
await upsertSyncMeta({
|
||||
tripId,
|
||||
lastSyncedAt: Date.now(),
|
||||
status: 'idle',
|
||||
tilesBbox: null,
|
||||
filesCachedCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
|
||||
async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
const nonPhotos = files.filter(f => f.url && !isPhoto(f))
|
||||
let cached = 0
|
||||
|
||||
for (const file of nonPhotos) {
|
||||
// Skip if already cached
|
||||
const existing = await offlineDb.blobCache.get(file.url!)
|
||||
if (existing) { cached++; continue }
|
||||
|
||||
try {
|
||||
const resp = await fetch(file.url!, { credentials: 'include' })
|
||||
if (!resp.ok) continue
|
||||
const blob = await resp.blob()
|
||||
await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() })
|
||||
cached++
|
||||
} catch {
|
||||
// Network failure — skip this file, will retry next sync
|
||||
}
|
||||
}
|
||||
|
||||
// Update filesCachedCount in syncMeta
|
||||
const tripId = files[0]?.trip_id
|
||||
if (tripId) {
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
if (meta) await upsertSyncMeta({ ...meta, filesCachedCount: cached })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
let _syncing = false
|
||||
|
||||
export const tripSyncManager = {
|
||||
/**
|
||||
* Sync all cache-eligible trips.
|
||||
* Evicts stale trips. Caches file blobs in the background.
|
||||
* No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine) return
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
|
||||
// Evict stale trips first
|
||||
const stale = trips.filter(isStale)
|
||||
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
|
||||
|
||||
// Sync eligible trips
|
||||
const toSync = trips.filter(shouldCache)
|
||||
for (const trip of toSync) {
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch (err) {
|
||||
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache file blobs + map tiles in background (don't block syncAll)
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
for (const trip of toSync) {
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
cacheFilesForTrip(files).catch(console.error)
|
||||
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
|
||||
}
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset syncing flag — useful in tests. */
|
||||
_resetSyncing(): void {
|
||||
_syncing = false
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user