Files
TREK/client/src/sync/tripSyncManager.ts
T
jubnl 544d5641d0 fix: resolve splash hang, dashboard skeleton, and sync-stuck regressions
- TripPlannerPage: change splash effect dep from `trip` (object ref) to
  `trip?.id` (primitive) — background refreshes no longer reset the 1500 ms
  timer on every new object reference, fixing the forever-splash on SPA nav
- tripRepo.list: await upserts on the cold-IDB path so the next mount reads
  from Dexie instead of hitting the network again, fixing the remount skeleton
- tripSyncManager: add stale-flag detection (>2 min resets _syncing), 90 s
  hard timeout via Promise.race, parallel post-sync prefetch via
  Promise.allSettled, and updated header comment to reflect manual-only policy
- OfflineTab: guard handleResync with a 120 s client-side timeout that
  interrupts and clears the spinner if syncAll stalls
2026-05-05 20:52:00 +02:00

249 lines
8.5 KiB
TypeScript

/**
* 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.
*
* syncAll() is manual-only — triggered via Settings → Offline tab.
* No automatic sync on login, dashboard load, or WS reconnect.
*/
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import {
offlineDb,
upsertTrip,
upsertDays,
upsertPlaces,
upsertPackingItems,
upsertTodoItems,
upsertBudgetItems,
upsertReservations,
upsertTripFiles,
upsertAccommodations,
upsertTripMembers,
upsertTags,
upsertCategories,
upsertSyncMeta,
clearTripData,
clearBlobCache,
clearAll,
} from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { useSettingsStore } from '../store/settingsStore'
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
// ── Types ─────────────────────────────────────────────────────────────────────
interface TripBundle {
trip: Trip
days: Day[]
places: Place[]
packingItems: PackingItem[]
todoItems: TodoItem[]
budgetItems: BudgetItem[]
reservations: Reservation[]
files: TripFile[]
accommodations: Accommodation[]
members: TripMember[]
}
// ── 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/')
}
function isQuotaError(err: unknown): boolean {
if (!(err instanceof Error)) return false
if (err.name === 'QuotaExceededError') return true
// Dexie wraps IDB errors: AbortError with inner QuotaExceededError
const inner = (err as { inner?: unknown }).inner
return inner instanceof Error && inner.name === 'QuotaExceededError'
}
// ── 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 upsertAccommodations(bundle.accommodations || [])
await upsertTripMembers(tripId, bundle.members || [])
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 ────────────────────────────────────────────────────────────────
const SYNC_TIMEOUT_MS = 90_000
const SYNC_STALE_MS = 120_000
let _syncing = false
let _interrupted = false
let _syncStartedAt = 0
export const tripSyncManager = {
/**
* Sync all cache-eligible trips.
* Evicts stale trips. Caches file blobs in the background.
* No-ops when offline or already syncing (unless stale flag).
*/
async syncAll(): Promise<void> {
// Treat a _syncing flag that's been set for >2 minutes as stale (e.g. page unload mid-sync)
if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return
if (!navigator.onLine) return
_syncing = true
_syncStartedAt = Date.now()
_interrupted = false
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('syncAll timeout')), SYNC_TIMEOUT_MS)
)
try {
await Promise.race([this._doSync(), timeout])
} catch (err) {
if (err instanceof Error && err.message === 'syncAll timeout') {
console.warn('[tripSync] syncAll timed out after 90 s — interrupting')
_interrupted = true
}
} finally {
_syncing = false
}
},
async _doSync(): Promise<void> {
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 — stop early if interrupted (e.g. user navigated to a trip page)
const toSync = trips.filter(shouldCache)
for (const trip of toSync) {
if (_interrupted) return
try {
await syncTrip(trip.id)
} catch (err) {
if (isQuotaError(err)) {
console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`)
try {
await clearTripData(trip.id)
await syncTrip(trip.id)
} catch (retryErr) {
if (isQuotaError(retryErr)) {
console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying')
await clearBlobCache()
try {
await syncTrip(trip.id)
} catch {
console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data')
await clearAll()
return
}
} else {
console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr)
}
}
} else {
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
}
}
}
if (_interrupted) return
// Cache global user data (tags + categories) — fire-and-forget
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {})
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})
// Cache file blobs + map tiles for all synced trips in parallel (fire-and-forget)
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
const prefetchWork = toSync
.filter(() => !_interrupted)
.map(async trip => {
const [files, places] = await Promise.all([
offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray(),
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
])
cacheFilesForTrip(files).catch(console.error)
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
})
await Promise.allSettled(prefetchWork)
},
/**
* Signal syncAll to stop after the current in-flight bundle request.
* Call when the user navigates to a trip page so loadTrip gets priority.
*/
interrupt(): void {
_interrupted = true
},
/** Reset syncing flag — useful in tests. */
_resetSyncing(): void {
_syncing = false
_interrupted = false
_syncStartedAt = 0
},
}