Files
TREK/client/src/sync/tripSyncManager.ts
T
Maurice 1f66ef1d32 fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile
fetched without a key since 26.08.2026, and both CartoDB presets plus the
built-in fallback are CARTO. The key is free and needs no account, so add
it as a setting rather than dropping the provider.

carto_api_key sits next to mapbox_access_token: per user in Settings/Map,
instance-wide under Admin/User Defaults, encrypted at rest, injected from
CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the
map provider, since a raster key says nothing about the renderer.

The key is appended as ?key= at render time instead of being stored in the
template, so a saved URL stays portable and survives a rotation. That is
also what reaches the four maps that pinned CARTO and ignored the user's
tile setting: atlas, collections, the public trip share and the public
journey. The two share pages read it from their token-gated payload, the
same way baseCurrency already travels.

Also lifts the release notice above page chrome. Its overlay sat at
z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither
on top nor modal. Adds a six-step layer scale to index.css and moves the
three notice shells onto it.

Closes #2054
Closes #2052
2026-08-27 12:06:55 +02:00

315 lines
12 KiB
TypeScript

/**
* 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 { getOfflinePrefs, isTripOfflineEnabled } 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)
}
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 < 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 || [])
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(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))
}
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<void> {
if (_syncing || !navigator.onLine || !isAuthed()) return
_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
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
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.
if (cacheTiles) {
whenIdle(async () => {
for (const trip of toSync) {
if (!isAuthed() || !navigator.onLine) return
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
await prefetchTilesForTrip(trip.id, places, tileUrl, undefined, cartoKey).catch(console.error)
}
})
}
} 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 the number of trips prepared.
*/
async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise<number> {
if (_syncing || !navigator.onLine || !isAuthed()) return 0
_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 0
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 0
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.
if (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 0
onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
await prefetchTilesForTrip(trip.id, places, tileUrl, true, cartoKey).catch(console.error)
}
}
onProgress?.({ phase: 'done', current: total, total })
return total
} finally {
_syncing = false
}
},
/** Reset syncing flag — useful in tests. */
_resetSyncing(): void {
_syncing = false
},
}