Spaces:
Sleeping
Sleeping
File size: 6,401 Bytes
cd99321 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | /**
* 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
},
}
|