Files
TREK/client/src/sync/tripSyncManager.ts
T
Maurice b61a55f037 fix(offline): archived trips on the device follow the eviction rules again
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.
2026-10-08 13:02:58 +02:00

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
},
}