Spaces:
Sleeping
Sleeping
| /** | |
| * 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, tagsApi, categoriesApi } from '../api/client' | |
| import { | |
| offlineDb, | |
| upsertTrip, | |
| upsertDays, | |
| upsertPlaces, | |
| upsertPackingItems, | |
| upsertTodoItems, | |
| upsertBudgetItems, | |
| upsertReservations, | |
| upsertTripFiles, | |
| upsertAccommodations, | |
| upsertTripMembers, | |
| upsertTags, | |
| upsertCategories, | |
| upsertSyncMeta, | |
| clearTripData, | |
| enforceBlobBudget, | |
| } from '../db/offlineDb' | |
| import { prefetchTilesForTrip } from './tilePrefetcher' | |
| import { isAuthed } from './authGate' | |
| 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/') | |
| } | |
| // ββ 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!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() }) | |
| cached++ | |
| } catch { | |
| // Network failure β skip this file, will retry next sync | |
| } | |
| } | |
| // Keep the blob cache within its size/count budget after adding new files. | |
| if (cached > 0) await enforceBlobBudget().catch(() => {}) | |
| // 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 || !isAuthed()) 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 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 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 | |
| }, | |
| } | |