feat(tours): tours read and write through the offline core

A tourRepo replaces the direct toursApi calls: reads fall back to a new
Dexie tours table, and a tour saved or edited offline is written there and
queued like a place, under one temporary id for the place and its facet.
The queue moves both to the real id on replay and drops both when the
server refuses the create. Trip sync caches each trip's tours while the
addon is on, so a tour no longer reads as a plain place offline.

The tour metrics move to shared so the offline copy shows the server's
numbers, and useTourPlaceIds keeps one Set per change as intended.
This commit is contained in:
Maurice
2026-10-07 21:30:45 +02:00
parent f7edd849ab
commit e0efa0314a
23 changed files with 839 additions and 133 deletions
+61 -21
View File
@@ -13,7 +13,7 @@ import { getOfflinePrefs } from './offlinePrefs'
import { randomId } from '../utils/randomId'
import type { QueuedMutation } from '../db/offlineDb'
import type { Table } from 'dexie'
import { roadtripPreferencesResponseSchema, assignmentSchema } from '@trek/shared'
import { roadtripPreferencesResponseSchema, assignmentSchema, tourCreateResponseSchema, type TourCreateResponse } from '@trek/shared'
import { cacheAssignment } from '../db/cacheAssignment'
// Map Dexie table names used in `resource` field → actual Dexie tables.
@@ -97,6 +97,60 @@ async function applyServerEntity(mutation: QueuedMutation, server: unknown): Pro
if (table) await table.put(server)
}
/**
* Roll back what an offline CREATE wrote under its temporary id, once the
* server refused it for good. A tour is a place plus its facet, so both go.
*/
async function dropTempRows(mutation: QueuedMutation): Promise<void> {
if (mutation.method === 'DELETE' || mutation.tempId === undefined || !mutation.resource) return
if (mutation.resource === 'tours') {
await offlineDb.places.delete(mutation.tempId)
await offlineDb.tours.delete(mutation.tempId)
return
}
const table = getTable(mutation.resource)
if (table) await table.delete(mutation.tempId)
}
/**
* A CREATE replayed and the server answered with the real id: later queued
* writes still aimed at the temporary one are pointed at it, in memory for
* this flush and durably for the next.
*/
async function remapTempId(mutation: QueuedMutation, realId: number, idMap: Map<number, number>): Promise<void> {
if (mutation.tempId === undefined || mutation.tempId === realId) return
idMap.set(mutation.tempId, realId)
await offlineDb.mutationQueue
.where('tripId')
.equals(mutation.tripId)
.filter(m => m.tempEntityId === mutation.tempId)
.modify(m => {
m.url = m.url.replace('{id}', String(realId))
m.entityId = realId
m.tempEntityId = undefined
})
}
/**
* A replayed tour write. The answer names the tour by its place, so the
* offline place and facet move from the temporary id to the real one. The
* place keeps its offline copy until the next list replaces it: the server
* created it from the same route.
*/
async function reconcileTour(mutation: QueuedMutation, saved: TourCreateResponse, idMap: Map<number, number>): Promise<void> {
const realId = saved.tour.place_id
await offlineDb.transaction('rw', offlineDb.places, offlineDb.tours, async () => {
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
const temp = await offlineDb.places.get(mutation.tempId)
await offlineDb.places.delete(mutation.tempId)
await offlineDb.tours.delete(mutation.tempId)
if (temp && !(await offlineDb.places.get(realId))) await offlineDb.places.put({ ...temp, id: realId })
}
await offlineDb.tours.put({ ...saved.tour, trip_id: mutation.tripId, waypoints: saved.waypoints })
})
await remapTempId(mutation, realId, idMap)
}
export const mutationQueue = {
/**
* Add a mutation to the queue.
@@ -222,7 +276,9 @@ export const mutationQueue = {
if (mutation.resource === 'assignments') {
await cacheAssignment(assignmentSchema.parse(response.data.assignment))
}
if (mutation.method !== 'DELETE' && mutation.resource) {
if (mutation.resource === 'tours') {
await reconcileTour(mutation, tourCreateResponseSchema.parse(response.data), idMap)
} else if (mutation.method !== 'DELETE' && mutation.resource) {
const table = getTable(mutation.resource)
if (table && response.data && typeof response.data === 'object') {
// Server returns { place: {...} } or { item: {...} } — grab first value
@@ -234,17 +290,7 @@ export const mutationQueue = {
// remap any queued mutations that still target the negative id.
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
await table.delete(mutation.tempId)
idMap.set(mutation.tempId, realId)
// Durable rewrite so dependents survive a flush boundary / reload.
await offlineDb.mutationQueue
.where('tripId')
.equals(mutation.tripId)
.filter(m => m.tempEntityId === mutation.tempId)
.modify(m => {
m.url = m.url.replace('{id}', String(realId))
m.entityId = realId
m.tempEntityId = undefined
})
await remapTempId(mutation, realId, idMap)
}
await table.put(entity)
// Advance the base-version token of any other queued edits to the
@@ -328,10 +374,7 @@ export const mutationQueue = {
if (isTerminal) {
// Permanent client error — roll back the phantom optimistic CREATE so
// it can't masquerade as synced, then mark failed and continue.
if (mutation.method !== 'DELETE' && mutation.tempId !== undefined && mutation.resource) {
const table = getTable(mutation.resource)
if (table) await table.delete(mutation.tempId)
}
await dropTempRows(mutation)
await offlineDb.mutationQueue.update(mutation.id, {
status: 'failed',
attempts: mutation.attempts + 1,
@@ -343,10 +386,7 @@ export const mutationQueue = {
// parked as failed instead of holding every later write forever.
const attempts = mutation.attempts + 1
if (attempts >= MAX_SERVER_ERROR_ATTEMPTS) {
if (mutation.method !== 'DELETE' && mutation.tempId !== undefined && mutation.resource) {
const table = getTable(mutation.resource)
if (table) await table.delete(mutation.tempId)
}
await dropTempRows(mutation)
await offlineDb.mutationQueue.update(mutation.id, { status: 'failed', attempts, lastError: String(err), retryAfter: undefined })
} else {
await offlineDb.mutationQueue.update(mutation.id, {