mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
b61a55f037
Keeping archived trips stopped the sync from wiping them, but left nothing that ever took one off the device. The trip sync never refreshes an archived trip, and the 7-day rule and the per-trip switch only looked at the active list, so an archived trip stayed until Clear cache however long ago it ended. The stale and switched-off rules now also run over the archived trips that are stored here, with the same eviction as an active trip: its rows go, its parked changes stay. The offline settings list the stored trips the server's list leaves out, so an archived trip gets a switch the user can turn off; before, the switches came from the active list only and could not reach it.
421 lines
19 KiB
TypeScript
421 lines
19 KiB
TypeScript
import { roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo'
|
|
/**
|
|
* 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, toursApi } from '../api/client'
|
|
import {
|
|
offlineDb,
|
|
upsertTrip,
|
|
replaceTripRows,
|
|
replaceTripTours,
|
|
upsertTripMembers,
|
|
upsertTags,
|
|
upsertCategories,
|
|
upsertSyncMeta,
|
|
clearTripData,
|
|
enforceBlobBudget,
|
|
} from '../db/offlineDb'
|
|
import { prefetchPlacesForTrip } from './placePrefetcher'
|
|
import { prefetchTilesForTrip } from './tilePrefetcher'
|
|
import { isAuthed } from './authGate'
|
|
import { isEffectivelyOffline } from './networkMode'
|
|
import { getOfflinePrefs, isTripOfflineEnabled, isTripPinned } from './offlinePrefs'
|
|
import { useSettingsStore } from '../store/settingsStore'
|
|
import { useAddonStore } from '../store/addonStore'
|
|
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 ───────────────────────────────────────────────────────────────────
|
|
|
|
// Trip dates are plain local calendar dates, so format ours the same way.
|
|
// toISOString() would hand back the UTC day and drop an ongoing trip a day
|
|
// early west of UTC (and keep a finished one a day too long east of it).
|
|
function ymd(d: Date): string {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
function todayStr(): string {
|
|
return ymd(new Date())
|
|
}
|
|
|
|
/**
|
|
* Run work once the browser has nothing better to do, with a ceiling so it
|
|
* still happens on browsers without requestIdleCallback (Safari) or in a tab
|
|
* that never goes idle.
|
|
*/
|
|
function whenIdle(fn: () => void | Promise<void>): void {
|
|
const run = () => { void Promise.resolve(fn()).catch(console.error) }
|
|
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 10_000 })
|
|
else setTimeout(run, 2_000)
|
|
}
|
|
|
|
/**
|
|
* Would the date rule keep this trip on its own? Exported so the offline
|
|
* settings screen can show the honest state of a trip's switch: a finished trip
|
|
* is not stored unless the user pins it, and a switch that reads "on" while
|
|
* nothing is stored is what made "Download for offline use" look broken (#2228).
|
|
*/
|
|
export function isDateEligible(trip: Trip): boolean {
|
|
if (!trip.end_date) return true // no end date → cache forever
|
|
return trip.end_date >= todayStr() // ongoing or future
|
|
}
|
|
|
|
function shouldCache(trip: Trip): boolean {
|
|
return isTripPinned(trip.id) || isDateEligible(trip)
|
|
}
|
|
|
|
function isStale(trip: Trip): boolean {
|
|
if (!trip.end_date) return false
|
|
if (isTripPinned(trip.id)) return false // the user asked for it, keep it
|
|
const cutoff = new Date()
|
|
cutoff.setDate(cutoff.getDate() - 7)
|
|
return trip.end_date < ymd(cutoff)
|
|
}
|
|
|
|
function isPhoto(file: TripFile): boolean {
|
|
return file.mime_type.startsWith('image/')
|
|
}
|
|
|
|
// Videos can be hundreds of MB — never prefetch them into the bounded offline
|
|
// blob cache, or a single clip would evict the trip's real documents (#823).
|
|
function isVideo(file: TripFile): boolean {
|
|
return file.mime_type.startsWith('video/')
|
|
}
|
|
|
|
// ── 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
|
|
|
|
// Replace, not merge: deletions made elsewhere while this device was offline
|
|
// reach the cache only through here, since the live events were missed.
|
|
await upsertTrip(bundle.trip)
|
|
await replaceTripRows('days', tripId, bundle.days)
|
|
await replaceTripRows('places', tripId, bundle.places)
|
|
await replaceTripRows('packingItems', tripId, bundle.packingItems)
|
|
await replaceTripRows('todoItems', tripId, bundle.todoItems)
|
|
await replaceTripRows('budgetItems', tripId, bundle.budgetItems)
|
|
await replaceTripRows('reservations', tripId, bundle.reservations)
|
|
await replaceTripRows('tripFiles', tripId, bundle.files)
|
|
await replaceTripRows('accommodations', tripId, bundle.accommodations || [])
|
|
await upsertTripMembers(tripId, bundle.members || [])
|
|
// Merged onto the existing row, not written over it: `put` replaces the whole
|
|
// record, and the row also carries `areaPlacesKey` — the fingerprint that says
|
|
// the cached places for this trip's area are still current. Losing it on every
|
|
// sync meant the prefetch re-downloaded the whole area (megabytes, and a bbox
|
|
// query upstream) on every login, every reconnect and every manual sync, for
|
|
// data that had not changed. The two fields below keep being reset on purpose,
|
|
// exactly as before.
|
|
const previous = await offlineDb.syncMeta.get(tripId)
|
|
await upsertSyncMeta({
|
|
...previous,
|
|
tripId,
|
|
lastSyncedAt: Date.now(),
|
|
status: 'idle',
|
|
tilesBbox: null,
|
|
filesCachedCount: 0,
|
|
})
|
|
|
|
// Last, and never fatal. Driving settings belong to an optional addon: the
|
|
// route answers 404 when it is off, and the repo's own offline fallback throws
|
|
// a bare Error when there is no cached row yet, which is exactly the state a
|
|
// first sync is in. Ahead of the writes and rethrowing anything but a 404, it
|
|
// threw the whole downloaded bundle away for that trip while the run still
|
|
// reported it stored, so Settings said "N trips ready" over an empty database.
|
|
await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional addon, optional cache */ })
|
|
|
|
// Tours, the same way: optional, and never fatal. Without them every Tour
|
|
// reads as a plain place offline. Asked only while the addon is on, so a
|
|
// sync of many trips does not spend a refused request on each.
|
|
if (useAddonStore.getState().isEnabled('tours')) {
|
|
try {
|
|
await replaceTripTours(tripId, (await toursApi.list(tripId)).tours)
|
|
} catch (err) {
|
|
console.warn(`[sync] tours for trip ${tripId} not cached:`, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
|
|
async function cacheFilesForTrip(tripId: number, files: TripFile[]): Promise<void> {
|
|
const nonPhotos = files.filter(f => f.url && !isPhoto(f) && !isVideo(f))
|
|
// `present` is what syncMeta.filesCachedCount reports (files available
|
|
// offline afterwards); `downloaded` only counts what this run actually wrote.
|
|
let present = 0
|
|
let downloaded = 0
|
|
|
|
for (const file of nonPhotos) {
|
|
// A logout mid-loop repoints offlineDb at the anonymous database, so anything
|
|
// written after it would leave the previous account's documents on the device.
|
|
if (!isAuthed()) return
|
|
// Skip if already cached
|
|
const existing = await offlineDb.blobCache.get(file.url!)
|
|
if (existing) { present++; 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() })
|
|
present++
|
|
downloaded++
|
|
} catch {
|
|
// Network failure — skip this file, will retry next sync
|
|
}
|
|
}
|
|
|
|
// Keep the blob cache within its size/count budget after adding new files.
|
|
if (downloaded > 0) await enforceBlobBudget().catch(() => {})
|
|
|
|
const meta = await offlineDb.syncMeta.get(tripId)
|
|
if (meta) await upsertSyncMeta({ ...meta, filesCachedCount: present })
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
/** Progress callback payload for a {@link tripSyncManager.prepareForOffline} run. */
|
|
export interface PrepareProgress {
|
|
/** Current stage. 'done' fires once at the end. */
|
|
phase: 'trips' | 'files' | 'tiles' | 'done'
|
|
/** 1-based index of the trip currently processed in this phase. */
|
|
current: number
|
|
/** Total trips to process in this phase. */
|
|
total: number
|
|
/** Name of the trip currently processed (for the UI). */
|
|
label?: string
|
|
}
|
|
|
|
let _syncing = false
|
|
|
|
/** The user's archived trips, or null when the list could not be read. */
|
|
function archivedTrips(): Promise<Trip[] | null> {
|
|
return (tripsApi.list({ archived: 1 }) as Promise<{ trips: Trip[] }>)
|
|
.then(r => r.trips)
|
|
.catch(() => null)
|
|
}
|
|
|
|
/**
|
|
* Decide which trips to cache and which to drop, honouring both the date rule
|
|
* and the user's per-trip offline choices (#1135 ask 2). Returns the trips to
|
|
* sync; clears Dexie for stale or user-disabled trips as a side effect.
|
|
*/
|
|
async function reconcileTrips(trips: Trip[], archived: Trip[] | null): Promise<Trip[]> {
|
|
// A cached trip the server lists neither as active nor as archived was
|
|
// deleted, or this user was removed from it. Its data has no business staying
|
|
// on the device. The plain list leaves archived trips out, so without the
|
|
// archived list every archived trip would read as gone; when that list could
|
|
// not be read, nothing is treated as gone this round.
|
|
const cached = await offlineDb.trips.toArray()
|
|
if (archived) {
|
|
const listed = new Set([...trips, ...archived].map(t => t.id))
|
|
const gone = cached.filter(t => t.id > 0 && !listed.has(t.id))
|
|
await Promise.all(gone.map(t => clearTripData(t.id).catch(console.error)))
|
|
}
|
|
|
|
// An archived trip on the device is kept but never synced again, so the date
|
|
// rule and the user's switch are the only things that ever take it off.
|
|
const cachedIds = new Set(cached.map(t => t.id))
|
|
const known = [...trips, ...(archived ?? []).filter(t => cachedIds.has(t.id))]
|
|
const stale = known.filter(isStale)
|
|
// Trips the user turned off explicitly are evicted regardless of date.
|
|
const disabled = known.filter(t => !isTripOfflineEnabled(t.id))
|
|
await Promise.all([...stale, ...disabled].map(t => clearTripData(t.id).catch(console.error)))
|
|
return trips.filter(t => shouldCache(t) && isTripOfflineEnabled(t.id))
|
|
}
|
|
|
|
/**
|
|
* Why a sync did nothing. The settings screen shows these: a run that never
|
|
* started used to be indistinguishable from a run that stored everything, so
|
|
* "Download for offline use" reported success while the cache stayed at zero
|
|
* and the user had no way to tell (#2228).
|
|
*/
|
|
export type SyncOutcome =
|
|
| { status: 'done'; trips: number }
|
|
| { status: 'skipped'; reason: 'busy' | 'offline' | 'signed-out' }
|
|
|
|
function skipReason(): SyncOutcome | null {
|
|
if (_syncing) return { status: 'skipped', reason: 'busy' }
|
|
// isEffectivelyOffline, not navigator.onLine: work-offline is a switch the user
|
|
// holds, and it survives a logout. A run started under it downloads over the
|
|
// real connection while every repo read answers from a cache that logging out
|
|
// just deleted, which is a sync that reports trips it did not store.
|
|
if (isEffectivelyOffline()) return { status: 'skipped', reason: 'offline' }
|
|
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
|
return null
|
|
}
|
|
|
|
export const tripSyncManager = {
|
|
/**
|
|
* Sync all cache-eligible trips.
|
|
* Evicts stale and user-disabled trips. Caches file blobs + map tiles in the
|
|
* background. No-ops when offline.
|
|
*/
|
|
async syncAll(): Promise<SyncOutcome> {
|
|
const skipped = skipReason()
|
|
if (skipped) return skipped
|
|
_syncing = true
|
|
try {
|
|
const [{ trips }, archived] = await Promise.all([tripsApi.list() as Promise<{ trips: Trip[] }>, archivedTrips()])
|
|
const toSync = await reconcileTrips(trips, archived)
|
|
|
|
for (const trip of toSync) {
|
|
// The gate is re-read per trip: a logout halfway through must not keep
|
|
// writing the old account's rows into the (now anonymous) offline DB.
|
|
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
|
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, so the
|
|
// gate has to be re-read when the response lands, not when it was issued:
|
|
// a logout in between would put these rows in the anonymous database.
|
|
tagsApi.list().then(d => { if (isAuthed()) void upsertTags(d.tags) }).catch(() => {})
|
|
categoriesApi.list().then(d => { if (isAuthed()) void upsertCategories(d.categories) }).catch(() => {})
|
|
|
|
// Cache file blobs + map tiles in background (don't block syncAll)
|
|
const cacheTiles = getOfflinePrefs().cacheTiles
|
|
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
|
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
|
|
for (const trip of toSync) {
|
|
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
|
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
|
cacheFilesForTrip(trip.id, files).catch(console.error)
|
|
}
|
|
|
|
// Map tiles last, and only once the browser goes idle. syncAll runs right
|
|
// after login, where the app is still mounting the first screen — starting
|
|
// a bulk tile download into that leaves the UI waiting behind our own
|
|
// background traffic.
|
|
// The place cache is not tied to the tile setting. That switch is about
|
|
// map tiles — "off keeps the cache to trip data + documents only" — and it
|
|
// is turned off for space, tens of megabytes of them. A trip's places are
|
|
// about a megabyte, and hanging them off that switch meant somebody who
|
|
// turned it off got an empty offline search with no explanation, while
|
|
// "prepare for offline" still reported success having stored no place at
|
|
// all.
|
|
whenIdle(async () => {
|
|
for (const trip of toSync) {
|
|
if (!isAuthed() || !navigator.onLine) return
|
|
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
|
// Tiles first when they are wanted: they are the bigger win, and the
|
|
// two should not compete for the connection.
|
|
if (cacheTiles) {
|
|
await prefetchTilesForTrip(trip.id, places, tileUrl, undefined, cartoKey).catch(console.error)
|
|
}
|
|
await prefetchPlacesForTrip(trip.id, places).catch(console.error)
|
|
}
|
|
})
|
|
return { status: 'done', trips: toSync.length }
|
|
} finally {
|
|
_syncing = false
|
|
}
|
|
},
|
|
|
|
/**
|
|
* "Prepare for offline" (#1135 ask 1): a fully-awaited sync the user runs while
|
|
* still online so everything they need is guaranteed on-device before they go
|
|
* offline. Unlike syncAll it awaits the file-blob and map-tile downloads up
|
|
* front instead of deferring them to idle time, reports progress, and forces
|
|
* the tile prefetch to run even for a bbox we believe is already cached.
|
|
*
|
|
* Returns how many trips were prepared, or why the run never started.
|
|
*/
|
|
async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise<SyncOutcome> {
|
|
const skipped = skipReason()
|
|
if (skipped) return skipped
|
|
_syncing = true
|
|
try {
|
|
const [{ trips }, archived] = await Promise.all([tripsApi.list() as Promise<{ trips: Trip[] }>, archivedTrips()])
|
|
const toSync = await reconcileTrips(trips, archived)
|
|
const total = toSync.length
|
|
|
|
// 1) Trip bundles (structured data).
|
|
let i = 0
|
|
for (const trip of toSync) {
|
|
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
|
onProgress?.({ phase: 'trips', current: ++i, total, label: trip.title })
|
|
try {
|
|
await syncTrip(trip.id)
|
|
} catch (err) {
|
|
console.error(`[tripSync] prepare failed for trip ${trip.id}:`, err)
|
|
}
|
|
}
|
|
|
|
// Global user data (tags + categories) — awaited here.
|
|
await Promise.all([
|
|
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}),
|
|
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}),
|
|
])
|
|
|
|
// 2) File blobs — awaited so "prepared" really means downloaded.
|
|
i = 0
|
|
for (const trip of toSync) {
|
|
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
|
onProgress?.({ phase: 'files', current: ++i, total, label: trip.title })
|
|
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
|
await cacheFilesForTrip(trip.id, files).catch(console.error)
|
|
}
|
|
|
|
// 3) Map tiles — awaited, and only when the user opted to store them. The
|
|
// places go with every run: "prepared" has to mean the offline search
|
|
// works, and the tile switch is about tens of megabytes of imagery, not
|
|
// about the megabyte a trip's places cost.
|
|
{
|
|
const wantTiles = getOfflinePrefs().cacheTiles
|
|
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
|
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
|
|
i = 0
|
|
for (const trip of toSync) {
|
|
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
|
// Reported only when there is a tile phase to report. The place
|
|
// prefetch rides along quietly, the way it always did — it is a
|
|
// megabyte, not the tens the progress bar exists for.
|
|
if (wantTiles) onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
|
|
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
|
if (wantTiles) {
|
|
await prefetchTilesForTrip(trip.id, places, tileUrl, true, cartoKey).catch(console.error)
|
|
}
|
|
await prefetchPlacesForTrip(trip.id, places, true).catch(console.error)
|
|
}
|
|
}
|
|
|
|
onProgress?.({ phase: 'done', current: total, total })
|
|
return { status: 'done', trips: total }
|
|
} finally {
|
|
_syncing = false
|
|
}
|
|
},
|
|
|
|
/** Reset syncing flag — useful in tests. */
|
|
_resetSyncing(): void {
|
|
_syncing = false
|
|
},
|
|
}
|