fix(planner): places put on a day, day names, day notes and trip details changed offline wait in the queue and sync once the connection is back

The day plan wrote straight to the API, so offline a place dropped on a day
snapped back with "Error assigning place", a renamed day or a new day note
failed, and a moved stop, a leg's travel mode or who joins a visit were lost.
Visits (put on a day, taken off, reordered, moved, travellers, leg mode), the
day's name, notes text and travel mode, and the day notes (add, edit, delete,
move) now go through assignmentRepo, dayRepo and dayNoteRepo and the mutation
queue: the cached day changes at once, a visit or note made offline gets a
temporary id that the edits after it wait for, and an online write held behind
an older queued one joins the queue, since none of these carry a version
token. A new order of a day that names a visit made offline is sent with its
real id, or without it when its create failed. Desktop, the phone timeline and
note sheet all write through the store, so both are covered.

The replay reads each answer for what it names: the day answer keeps the
cached notes and visits, the participants answer lands on its visit, and a
visit the server refuses with 409 (a Tour already on that day) fails instead
of waiting as a conflict no retry can solve.

The trip's name, description, reminder and the archive flag wait in the queue
too, from the planner, the phone and the dashboard. What only the server can
do says so instead of failing: new dates, a day count, a currency or a cover,
a new trip, a copy or a delete, members, guests, share and invite links, and
a new order of the days, which moves the dates of the bookings.

Refs #2601
This commit is contained in:
Maurice
2026-10-12 20:17:57 +02:00
parent 92c48fba22
commit bdd8c08dd4
85 changed files with 1557 additions and 88 deletions
+121 -6
View File
@@ -15,7 +15,7 @@ import type { QueuedMutation } from '../db/offlineDb'
import type { Table } from 'dexie'
import { roadtripPreferencesResponseSchema, assignmentSchema, tourCreateResponseSchema, type TourCreateResponse } from '@trek/shared'
import { cacheAssignment } from '../db/cacheAssignment'
import type { BudgetItem, BudgetItemMember, Reservation, TripFile } from '../types'
import type { Assignment, AssignmentParticipant, BudgetItem, BudgetItemMember, Day, DayNote, Reservation, Trip, TripFile } from '../types'
// Map Dexie table names used in `resource` field → actual Dexie tables.
function getTable(resource: string): Table | undefined {
@@ -189,6 +189,15 @@ async function dropTempRows(mutation: QueuedMutation): Promise<void> {
await offlineDb.tours.delete(mutation.tempId)
return
}
// Visits and day notes live on the cached day, not in a table of their own.
if (mutation.resource === 'assignments' || mutation.resource === 'dayNotes') {
const tempId = mutation.tempId
await offlineDb.days.where('trip_id').equals(mutation.tripId).modify(day => {
if (mutation.resource === 'assignments') day.assignments = (day.assignments ?? []).filter(a => a.id !== tempId)
else day.notes_items = (day.notes_items ?? []).filter(n => n.id !== tempId)
})
return
}
const table = getTable(mutation.resource)
if (table) await table.delete(mutation.tempId)
}
@@ -226,6 +235,10 @@ const TEMP_REF_FIELDS = ['place_id', 'reservation_id', 'accommodation_id'] as co
// Objects inside a body that carry such fields of their own: the stay a hotel
// booking creates alongside names its place.
const TEMP_REF_OBJECTS = ['create_accommodation'] as const
// Resources whose writes carry no version token (see the 409 branch of the flush).
const UNVERSIONED_RESOURCES = new Set(['assignments', 'days', 'dayNotes'])
// A new order of a day's visits can name visits created offline.
const TEMP_REF_LISTS = ['orderedIds'] as const
/**
* The body with every link to an offline-created entity pointed at the real id
@@ -249,9 +262,34 @@ function remapBodyRefs(body: unknown, idMap: Map<number, number>): unknown {
remapped ??= { ...fields }
remapped[key] = next
}
for (const key of TEMP_REF_LISTS) {
const list = fields[key]
if (!Array.isArray(list) || !list.some(id => typeof id === 'number' && idMap.has(id))) continue
remapped ??= { ...fields }
remapped[key] = list.map(id => (typeof id === 'number' ? idMap.get(id) ?? id : id))
}
return remapped ?? body
}
/**
* The body as it is sent: every link the queue has learned the real id of, and
* a list without the temporary ids it has not. Those name a create that did not
* sync, which the server would not know.
*/
function bodyToSend(body: unknown, idMap: Map<number, number>): unknown {
const remapped = remapBodyRefs(body, idMap)
if (!remapped || typeof remapped !== 'object' || Array.isArray(remapped)) return remapped
const fields = remapped as Record<string, unknown>
let trimmed: Record<string, unknown> | null = null
for (const key of TEMP_REF_LISTS) {
const list = fields[key]
if (!Array.isArray(list) || !list.some(id => typeof id === 'number' && id < 0)) continue
trimmed ??= { ...fields }
trimmed[key] = list.filter(id => typeof id !== 'number' || id > 0)
}
return trimmed ?? remapped
}
/**
* A replayed expense write. Only `item` names the expense: the members route
* answers `{ members, item }` with the item's bare row and the paid tick answers
@@ -301,6 +339,80 @@ async function reconcileTripFile(data: unknown): Promise<void> {
await offlineDb.tripFiles.put({ ...local, ...file.file })
}
/**
* A replayed visit write. A create moves the visit on its cached day from the
* temporary id to the real one; the visit routes answer `{ assignment }`, the
* participants route `{ participants }`, and a reorder or delete names no visit.
*/
async function reconcileAssignment(mutation: QueuedMutation, data: unknown, entityId: number | undefined, idMap: Map<number, number>): Promise<void> {
const answer = (data && typeof data === 'object' ? data : {}) as { assignment?: unknown; participants?: AssignmentParticipant[] }
if (answer.assignment) {
const saved = assignmentSchema.parse(answer.assignment) as Assignment
const tempId = mutation.tempId
if (tempId !== undefined && tempId !== saved.id) {
await offlineDb.days.where('id').equals(saved.day_id).modify(day => {
day.assignments = (day.assignments ?? []).map(a => (a.id === tempId ? { ...a, ...saved } : a))
})
await remapTempId(mutation, saved.id, idMap)
}
await cacheAssignment(saved)
return
}
if (answer.participants && entityId !== undefined) {
const participants = answer.participants
await offlineDb.days.where('trip_id').equals(mutation.tripId).modify(day => {
if (!day.assignments?.some(a => a.id === entityId)) return
day.assignments = day.assignments.map(a => (a.id === entityId ? { ...a, participants } : a))
})
}
}
/**
* A replayed day write. The day answers `{ day }` without its notes, so the
* cached notes stay, and so do the cached visits: they may hold writes still
* waiting behind this one.
*/
async function reconcileDay(data: unknown): Promise<void> {
const day = data && typeof data === 'object' ? (data as { day?: Day }).day : undefined
if (!day || typeof day.id !== 'number') return
const { assignments: _assignments, notes_items: _notes, ...fields } = day
await offlineDb.days.where('id').equals(day.id).modify(row => { Object.assign(row, fields) })
}
/**
* A replayed trip write. The answer lays its fields over the cached trip, which
* keeps the counts the list shape carries and the answer does not.
*/
async function reconcileTrip(data: unknown): Promise<void> {
const trip = data && typeof data === 'object' ? (data as { trip?: Trip }).trip : undefined
if (!trip || typeof trip.id !== 'number') return
const local = await offlineDb.trips.get(trip.id)
await offlineDb.trips.put({ ...local, ...trip })
}
/** A replayed day note write: the note lands on its cached day, a create under its real id. */
async function reconcileDayNote(mutation: QueuedMutation, data: unknown, idMap: Map<number, number>): Promise<void> {
const note = data && typeof data === 'object' ? (data as { note?: DayNote }).note : undefined
if (!note || typeof note.id !== 'number') return
const replaceId = mutation.tempId ?? note.id
await offlineDb.days.where('id').equals(note.day_id).modify(day => {
const notes = day.notes_items ?? []
day.notes_items = notes.some(n => n.id === replaceId)
? notes.map(n => (n.id === replaceId ? { ...n, ...note } : n))
: [...notes.filter(n => n.id !== note.id), note]
})
if (mutation.tempId !== undefined && mutation.tempId !== note.id) await remapTempId(mutation, note.id, idMap)
}
/** Visits, days, day notes and the trip itself, each from the answer its route gives. */
async function reconcilePlanWrite(mutation: QueuedMutation, data: unknown, entityId: number | undefined, idMap: Map<number, number>): Promise<void> {
if (mutation.method === 'DELETE') return
if (mutation.resource === 'assignments') await reconcileAssignment(mutation, data, entityId, idMap)
else if (mutation.resource === 'days') await reconcileDay(data)
else if (mutation.resource === 'dayNotes') await reconcileDayNote(mutation, data, idMap)
else if (mutation.resource === 'trips') await reconcileTrip(data)
}
/**
* 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
@@ -523,7 +635,7 @@ export const mutationQueue = {
const response = await apiClient.request({
method: mutation.method,
url: reqUrl,
data: remapBodyRefs(mutation.body, idMap),
data: bodyToSend(mutation.body, idMap),
headers,
})
@@ -532,9 +644,9 @@ export const mutationQueue = {
await offlineDb.roadtripPreferences.put(saved)
}
// Apply canonical server response to Dexie
if (mutation.resource === 'assignments') {
await cacheAssignment(assignmentSchema.parse(response.data.assignment))
}
// The plan's own rows have no table the generic branches below would
// write, so those pass over them.
await reconcilePlanWrite(mutation, response.data, reqEntityId, idMap)
if (mutation.resource === 'tours') {
await reconcileTour(mutation, tourCreateResponseSchema.parse(response.data), idMap)
} else if (mutation.resource === 'budgetItems' && mutation.method !== 'DELETE') {
@@ -609,7 +721,10 @@ export const mutationQueue = {
// the user's strategy instead of failing it. Deliberately scoped to
// edits: an offline DELETE is "delete wins" by design (no CAS on the
// delete path), so it never reaches here. See the wiki Offline doc.
if (httpStatus === 409 && mutation.method !== 'DELETE') {
// Visits, days and day notes carry no version, so a 409 there is a
// refusal (a Tour already on that day) that no retry can change, and
// it fails like any other 4xx.
if (httpStatus === 409 && mutation.method !== 'DELETE' && !UNVERSIONED_RESOURCES.has(mutation.resource ?? '')) {
const server = extractConflictServer(err)
const strategy = getOfflinePrefs().conflictStrategy
if (strategy === 'server') {