Files
TREK/client/src/sync/tripSyncManager.ts
T
Maurice 89bae414ff fix(offline): a missing driving-settings row no longer discards the trip bundle
syncTrip read the roadtrip preferences before writing anything and rethrew
everything but a 404. That read has an offline fallback which throws a bare
Error when there is no cached row, which is the state a first sync is in and
the state a logout leaves behind, so the whole downloaded bundle for that trip
was dropped while the run still counted it. Settings reported "N trips stored"
over an empty database, and the trips were gone the moment the connection was.
The read moved to the end and is never fatal now: it belongs to an optional
addon and the addon answers 404 when it is off.

Two things that fed it, from the same trace. A sync run started on
navigator.onLine alone, so work-offline downloaded over the real connection
while every repo read answered from cache. And work-offline itself survived a
logout, in localStorage, while step six of the same logout deleted the database
it reads from, so the next account came up believing it was offline with
nothing to be offline with.

Also in this pass: deleting a hotel booking released its day stop after
deleting the booking row, which now clears the stop's pointer by trigger. The
release goes first, or the stop stands with nothing left to reach it.
2026-09-13 23:48:59 +02:00

390 lines
17 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 } from '../api/client'
import {
offlineDb,
upsertTrip,
upsertDays,
upsertPlaces,
upsertPackingItems,
upsertTodoItems,
upsertBudgetItems,
upsertReservations,
upsertTripFiles,
upsertAccommodations,
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 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
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 || [])
// 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 */ })
}
/** 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
/**
* 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[]): Promise<Trip[]> {
const stale = trips.filter(isStale)
// Trips the user turned off explicitly are evicted regardless of date.
const disabled = trips.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 } = await tripsApi.list() as { trips: Trip[] }
const toSync = await reconcileTrips(trips)
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()) upsertTags(d.tags) }).catch(() => {})
categoriesApi.list().then(d => { if (isAuthed()) 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 } = await tripsApi.list() as { trips: Trip[] }
const toSync = await reconcileTrips(trips)
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
},
}