diff --git a/client/scripts/offline-baseline.json b/client/scripts/offline-baseline.json
index 08f94545a..659d61731 100644
--- a/client/scripts/offline-baseline.json
+++ b/client/scripts/offline-baseline.json
@@ -162,7 +162,6 @@
"src/pages/tripPlanner/useBookingEdits.ts",
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
"src/pages/tripPlanner/usePlannerDialogs.ts",
- "src/pages/tripPlanner/useRoadtripStopEdits.ts",
"src/pages/tripPlanner/useTripPlanner.ts",
"src/pages/vacay/useVacay.ts"
]
diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json
index a56a72d74..f3164d131 100644
--- a/client/scripts/strict-baseline.json
+++ b/client/scripts/strict-baseline.json
@@ -45,7 +45,7 @@
"src/components/Map/transitGeometry.ts": 1,
"src/components/Map/useDawarichTrailGL.test.ts": 4,
"src/components/Map/useMarkerThumbs.ts": 1,
- "src/components/PDF/TripPDF.tsx": 44,
+ "src/components/PDF/TripPDF.tsx": 40,
"src/components/Packing/PackingListPanelItemRow.test.tsx": 2,
"src/components/Planner/DayPlanSidebar.constants.ts": 1,
"src/components/Planner/DayPlanSidebar.tsx": 61,
diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx
index 8f9f06a40..d422a2afc 100644
--- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx
+++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx
@@ -3,9 +3,11 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
+import { useToast } from '../shared/Toast'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi, healthApi } from '../../api/client'
import { saveImportFiles } from '../../db/offlineDb'
+import { isEffectivelyOffline } from '../../sync/networkMode'
import { useBackgroundTasksStore, taskFoundSomething, type BackgroundImportTask } from '../../store/backgroundTasksStore'
import type { ReceiptRead } from '@trek/shared'
@@ -33,6 +35,7 @@ function readingKey(task: BackgroundImportTask): string {
*/
export default function BackgroundTasksWidget() {
const { t } = useTranslation()
+ const toast = useToast()
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
@@ -56,6 +59,8 @@ export default function BackgroundTasksWidget() {
const retryWithAi = async (task: BackgroundImportTask) => {
const files = task.sourceFiles
if (!files || files.length === 0 || retrying === task.id) return
+ // Offline the card stays as it is, with its button, for a try once the connection is back.
+ if (isEffectivelyOffline()) { toast.error(t('reservations.needsConnection')); return }
setRetrying(task.id)
try {
const { jobId } = await reservationsApi.importBookingAsync(task.tripId, files, 'force-ai')
diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx
index c882456ad..83608b1ea 100644
--- a/client/src/components/PDF/TripPDF.tsx
+++ b/client/src/components/PDF/TripPDF.tsx
@@ -2,7 +2,8 @@
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Sailboat, CableCar, Bike, CarTaxiFront, Route, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, ParkingSquare, LucideIcon } from 'lucide-react'
-import { accommodationsApi, mapsApi, pluginsApi } from '../../api/client'
+import { mapsApi, pluginsApi } from '../../api/client'
+import { accommodationRepo } from '../../repo/accommodationRepo'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit, BudgetItem } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
@@ -254,7 +255,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
const range = longDateRange(sorted, loc)
const coverImg = safeImg(trip?.cover_image)
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
- const accommodations = await accommodationsApi.list(trip.id);
+ const accommodations = await accommodationRepo.list(trip.id);
// The endpoint answers `{ accommodations: [...] }`, not a bare array. Unwrapped once
// here so every reader below gets the list itself — passing the envelope on is what
// cost the route map its hotel legs, and silently (#1736).
diff --git a/client/src/components/Planner/AirTrailImportModal.test.tsx b/client/src/components/Planner/AirTrailImportModal.test.tsx
index 5bc103ffc..291a751ee 100644
--- a/client/src/components/Planner/AirTrailImportModal.test.tsx
+++ b/client/src/components/Planner/AirTrailImportModal.test.tsx
@@ -1,4 +1,4 @@
-// FE-PLANNER-AIRTRAIL-001 to FE-PLANNER-AIRTRAIL-020
+// FE-PLANNER-AIRTRAIL-001 to FE-PLANNER-AIRTRAIL-022
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -8,6 +8,7 @@ import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildReservation } from '../../../tests/helpers/factories';
import type { AirtrailFlight } from '@trek/shared';
+import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
import AirTrailImportModal, { detectConnections } from './AirTrailImportModal';
const toasts: Array<[string, string]> = [];
@@ -345,4 +346,16 @@ describe('AirTrailImportModal', () => {
await screen.findByText('Finnair AY1502');
expect(screen.queryByText(/^Imported$/)).not.toBeInTheDocument();
});
+ it('FE-PLANNER-AIRTRAIL-022: offline the dialog says it needs a connection instead of asking AirTrail (#2601)', async () => {
+ const flights = vi.fn();
+ server.use(http.get('/api/integrations/airtrail/flights', () => { flights(); return HttpResponse.json({ flights: [flight()] }); }));
+ setForcedOffline(true);
+ try {
+ render();
+ expect(await screen.findByText('This needs a connection. Try again once TREK is back online.')).toBeInTheDocument();
+ } finally {
+ _resetNetworkMode();
+ }
+ expect(flights).not.toHaveBeenCalled();
+ });
});
diff --git a/client/src/components/Planner/AirTrailImportModal.tsx b/client/src/components/Planner/AirTrailImportModal.tsx
index df98ee47c..1f5d204e8 100644
--- a/client/src/components/Planner/AirTrailImportModal.tsx
+++ b/client/src/components/Planner/AirTrailImportModal.tsx
@@ -5,8 +5,9 @@ import { Plane, X, Check } from 'lucide-react'
import type { AirtrailFlight, AirtrailImportResult } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
-import { airtrailApi, reservationsApi } from '../../api/client'
+import { airtrailApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
+import { isEffectivelyOffline } from '../../sync/networkMode'
import { parseReservationMetadata } from '../../utils/flightLegs'
interface AirTrailImportModalProps {
@@ -71,6 +72,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
const trip = useTripStore(s => s.trip)
const reservations = useTripStore(s => s.reservations)
const loadReservations = useTripStore(s => s.loadReservations)
+ const deleteReservation = useTripStore(s => s.deleteReservation)
const mouseDownTarget = useRef(null)
const [loading, setLoading] = useState(false)
@@ -117,6 +119,12 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
setError('')
setSelected(new Set())
setJoinOff(new Set())
+ // The flights live on the AirTrail server, which only a connection reaches.
+ if (isEffectivelyOffline()) {
+ setFlights([])
+ setError(t('reservations.needsConnection'))
+ return
+ }
setLoading(true)
airtrailApi
.flights()
@@ -196,6 +204,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
const handleImport = async () => {
const ids = [...selected].filter(id => !importedIds.has(id))
if (ids.length === 0 || importing) return
+ if (isEffectivelyOffline()) { setError(t('reservations.needsConnection')); return }
setImporting(true)
setError('')
try {
@@ -209,7 +218,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
const linked = useTripStore.getState().reservations.filter(
r => r.external_source === 'airtrail' && r.external_id && imported.includes(String(r.external_id)),
)
- await Promise.all(linked.map(r => reservationsApi.delete(tripId, r.id).catch(() => {})))
+ await Promise.all(linked.map(r => deleteReservation(tripId, r.id).catch(() => {})))
await loadReservations(tripId)
})
toast.success(t('reservations.airtrail.imported', { count: imported.length }))
diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx
index 21d754f03..aa84b9eef 100644
--- a/client/src/components/Planner/BookingImportModal.tsx
+++ b/client/src/components/Planner/BookingImportModal.tsx
@@ -7,6 +7,7 @@ import { llmRepo } from '../../repo/llmRepo'
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import { saveImportFiles } from '../../db/offlineDb'
+import { isEffectivelyOffline } from '../../sync/networkMode'
interface BookingImportModalProps {
isOpen: boolean
@@ -101,6 +102,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
// Start the parse in the background and close — the widget takes it from here.
const handleParse = async () => {
if (files.length === 0 || loading) return
+ // Reading a booking runs on the server: offline there is nothing to send it to.
+ if (isEffectivelyOffline()) { setError(t('reservations.needsConnection')); return }
setLoading(true)
setError('')
try {
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index b78a57284..63ac75d59 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -10,7 +10,7 @@ import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
-import { reservationsApi, daysApi } from '../../api/client'
+import { daysApi } from '../../api/client'
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
import { dayViaLookup, routeLeg, type LegEnd, type ViasByDay } from '../Map/dayRouteVias'
import GoogleMapsIcon from '../shared/GoogleMapsIcon'
@@ -522,7 +522,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// day shows an order that only exists in this tab.
// The ids stay marked as initialised: the effect reruns on every reservations
// change, so clearing them here would retry the same failing write in a loop.
- reservationsApi.updatePositions(tripId, positions).catch(() => {
+ tripActions.updateReservationPositions(tripId, positions).catch(() => {
useTripStore.setState(state => ({
reservations: state.reservations.map(r => {
const p = before.find(x => x.id === r.id)
@@ -906,7 +906,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
if (transportUpdates.length) {
onRouteRefresh?.()
- await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
+ await tripActions.updateReservationPositions(tripId, transportUpdates, dayId)
for (const tu of transportUpdates) dropRollback(tu.id)
}
for (const n of noteUpdates) {
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index 1ac57913e..58b61b849 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -26,7 +26,7 @@ import { typeInfo } from './bookings/bookingsModel'
import type { StatusTone } from './bookings/bookingParts'
import { RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate } from './reservationFormModel'
import { useReservationForm } from './useReservationForm'
-import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel'
+import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
import { useBookingFileAttach } from './useBookingFileAttach'
@@ -126,6 +126,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
e?.preventDefault?.()
if (!form.title.trim()) return
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
+ if (filesNeedConnection(pendingFiles, !!onFileUpload)) { toast.error(t('reservations.filesNeedConnection')); return }
setIsSaving(true)
try {
const saveData = buildSaveData({ assignmentId: form.assignment_id, isEdit: !!reservation?.id, withCheckInEnd: true })
diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx
index 8abedbd37..16c12efc9 100644
--- a/client/src/components/Planner/TransportModal.tsx
+++ b/client/src/components/Planner/TransportModal.tsx
@@ -30,7 +30,7 @@ import {
transportDayOptions, type StationWaypointForm, type WaypointForm,
} from './transportEndpoints'
import { useTransportForm } from './useTransportForm'
-import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel'
+import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
import { useBookingFileAttach } from './useBookingFileAttach'
@@ -130,9 +130,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
})
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
- const handleSubmit = async (e?: React.FormEvent) => {
+ const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault()
if (!form.title.trim()) return
+ if (filesNeedConnection(pendingFiles, !!onFileUpload)) { toast.error(t('reservations.filesNeedConnection')); return }
+ return saveTransport()
+ }
+
+ const saveTransport = async () => {
setIsSaving(true)
try {
const saved = await onSave(payload(days, {
diff --git a/client/src/components/Planner/bookingFormModel.test.ts b/client/src/components/Planner/bookingFormModel.test.ts
index 4b9fdc5b0..92eef146f 100644
--- a/client/src/components/Planner/bookingFormModel.test.ts
+++ b/client/src/components/Planner/bookingFormModel.test.ts
@@ -1,13 +1,15 @@
-// FE-PLANNER-BOOKINGFORM-001 to -009: the travellers, picked files, attached files
+// FE-PLANNER-BOOKINGFORM-001 to -010: the travellers, picked files, attached files
// and expense requests of the transport and booking forms, desktop and phone alike.
import { typeToCostCategory } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { buildBudgetItem, buildDay, buildReservation, buildTripFile } from '../../../tests/helpers/factories';
+import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
import type { Reservation } from '../../types';
import {
attachedBookingFiles,
expenseRequestAfterSave,
+ filesNeedConnection,
pendingImportExpense,
toggledTraveler,
travelerIdsOf,
@@ -113,4 +115,16 @@ describe('bookingFormModel', () => {
expect(attachedBookingFiles(files, null, [3])).toEqual([]);
expect(attachedBookingFiles(files, undefined, [])).toEqual([]);
});
+ it('FE-PLANNER-BOOKINGFORM-010: only files that would go up while offline hold the save back (#2601)', () => {
+ const file = new File(['x'], 'ticket.pdf');
+ expect(filesNeedConnection([file], true)).toBe(false);
+ setForcedOffline(true);
+ try {
+ expect(filesNeedConnection([file], true)).toBe(true);
+ expect(filesNeedConnection([], true)).toBe(false);
+ expect(filesNeedConnection([file], false)).toBe(false);
+ } finally {
+ _resetNetworkMode();
+ }
+ });
});
diff --git a/client/src/components/Planner/bookingFormModel.ts b/client/src/components/Planner/bookingFormModel.ts
index fcac7433f..6e88e0931 100644
--- a/client/src/components/Planner/bookingFormModel.ts
+++ b/client/src/components/Planner/bookingFormModel.ts
@@ -1,5 +1,6 @@
import { typeToCostCategory } from '@trek/shared';
+import { isEffectivelyOffline } from '../../sync/networkMode';
import type { BudgetItem, Day, Reservation, TripFile } from '../../types';
import type { BookingExpenseRequest } from './BookingCostsSection.types';
import { importedPriceEntry } from './importedPrice';
@@ -53,6 +54,15 @@ export async function uploadBookingFiles(
}
}
+/**
+ * Whether saving now would have to upload picked files while TREK is offline. A
+ * file cannot wait in the offline queue the way the booking can, so the form asks
+ * to save without the new files instead of storing a booking that loses them.
+ */
+export function filesNeedConnection(files: File[], canUpload: boolean): boolean {
+ return canUpload && files.length > 0 && isEffectivelyOffline();
+}
+
/**
* Which expense the user asked for while saving a booking: a new one or an edit of
* the linked one. The form saves itself first, so the request can name the saved id.
diff --git a/client/src/components/Planner/useBookingFileAttach.test.ts b/client/src/components/Planner/useBookingFileAttach.test.ts
index c63f756f2..a506ab9e7 100644
--- a/client/src/components/Planner/useBookingFileAttach.test.ts
+++ b/client/src/components/Planner/useBookingFileAttach.test.ts
@@ -1,4 +1,4 @@
-// FE-PLANNER-BOOKINGFILES-001 to -005: the Files row of the desktop booking and
+// FE-PLANNER-BOOKINGFILES-001 to -006: the Files row of the desktop booking and
// transport dialogs.
import { act, renderHook } from '@testing-library/react';
import type React from 'react';
@@ -6,6 +6,7 @@ import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
+import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
import type { TripFile } from '../../types';
import { type BookingFileAttachOptions, useBookingFileAttach } from './useBookingFileAttach';
@@ -93,4 +94,21 @@ describe('useBookingFileAttach', () => {
const { result } = setup({ reservation: null }, [buildTripFile({ id: 1, reservation_id: 5 })]);
expect(result.current.attach.attachedFiles).toEqual([]);
});
+ it('FE-PLANNER-BOOKINGFILES-006: offline a file on a saved booking is not sent and says it needs a connection (#2601)', async () => {
+ const onFileUpload = vi.fn(async (_fd: FormData) => undefined);
+ const { result, toast } = setup({ onFileUpload });
+ const event = changeEvent(new File(['x'], 'ticket.pdf'));
+ setForcedOffline(true);
+ try {
+ await act(async () => {
+ await result.current.attach.handleFileChange(event);
+ });
+ } finally {
+ _resetNetworkMode();
+ }
+ expect(onFileUpload).not.toHaveBeenCalled();
+ expect(toast.error).toHaveBeenCalledWith('reservations.needsConnection');
+ expect(event.target.value).toBe('');
+ expect(result.current.pendingFiles).toEqual([]);
+ });
});
diff --git a/client/src/components/Planner/useBookingFileAttach.ts b/client/src/components/Planner/useBookingFileAttach.ts
index 211eeec18..25cb9fa47 100644
--- a/client/src/components/Planner/useBookingFileAttach.ts
+++ b/client/src/components/Planner/useBookingFileAttach.ts
@@ -1,6 +1,7 @@
import type React from 'react';
import { type Dispatch, type SetStateAction, useState } from 'react';
+import { isEffectivelyOffline } from '../../sync/networkMode';
import type { TripFile } from '../../types';
import { attachedBookingFiles } from './bookingFormModel';
@@ -35,7 +36,12 @@ export function useBookingFileAttach({
const handleFileChange = async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file) return;
- if (reservation?.id) {
+ if (reservation?.id && isEffectivelyOffline()) {
+ // A file goes up at once on a saved booking, and an upload cannot wait in
+ // the offline queue.
+ toast.error(t('reservations.needsConnection'));
+ e.target.value = '';
+ } else if (reservation?.id) {
setUploadingFile(true);
try {
if (onFileUpload) {
diff --git a/client/src/components/Planner/useDayDetail.ts b/client/src/components/Planner/useDayDetail.ts
index 1cf61c9ed..538baa96e 100644
--- a/client/src/components/Planner/useDayDetail.ts
+++ b/client/src/components/Planner/useDayDetail.ts
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import type { WeatherResult } from '@trek/shared'
-import { weatherApi, accommodationsApi } from '../../api/client'
+import { weatherApi } from '../../api/client'
+import { accommodationRepo } from '../../repo/accommodationRepo'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { applyStayStops } from '../../store/stayStops'
import { stayCheckoutDay, stayRequestBody } from './stayFormModel'
@@ -29,8 +30,8 @@ const EMPTY_HOTEL_FORM: HotelForm = { check_in: '', check_in_end: '', check_out:
* server moved for it, for the desktop day panel and the phone accommodation
* sheet alike. Throws when the server refuses the write.
*/
-export async function writeStay(tripId: number, accId: number | null, body: Parameters[1]) {
- const data = accId != null ? await accommodationsApi.update(tripId, accId, body) : await accommodationsApi.create(tripId, body)
+export async function writeStay(tripId: number, accId: number | null, body: Parameters[1]) {
+ const data = accId != null ? await accommodationRepo.update(tripId, accId, body) : await accommodationRepo.create(tripId, body)
applyStayStops(data)
return data
}
@@ -109,7 +110,7 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat:
useEffect(() => {
if (!tripId) return
let cancelled = false
- accommodationsApi.list(tripId)
+ accommodationRepo.list(tripId)
.then(data => {
if (cancelled) return
const all: Accommodation[] = data.accommodations || []
@@ -148,7 +149,7 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat:
await writeStay(tripId, accommodation.id, stayRequestBody(hotelForm, hotelDayRange))
setShowHotelPicker(false)
setHotelForm(EMPTY_HOTEL_FORM)
- const d = await accommodationsApi.list(tripId)
+ const d = await accommodationRepo.list(tripId)
const all: Accommodation[] = d.accommodations || []
setAccommodations(all)
const forDay = all.filter(isForDay)
@@ -160,7 +161,7 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat:
const updateAccommodationField = async (field: string, value: string | null) => {
if (!accommodation) return
try {
- const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
+ const data = await accommodationRepo.update(tripId, accommodation.id, { [field]: value || null })
applyStayStops(data)
setAccommodation(data.accommodation)
onAccommodationChange?.()
@@ -175,7 +176,7 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat:
*/
const handleRemoveAccommodation = async (target: Accommodation | null = accommodation) => {
if (!target) return
- applyStayStops(await accommodationsApi.delete(tripId, target.id))
+ applyStayStops(await accommodationRepo.delete(tripId, target.id))
const updated = accommodations.filter(a => a.id !== target.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(isForDay))
diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
index e2cae4d74..e1efd8203 100644
--- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
+++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTripStore } from '../../../../store/tripStore'
import { useRouteCalculation } from '../../../../hooks/useRouteCalculation'
-import { reservationsApi, weatherApi } from '../../../../api/client'
+import { weatherApi } from '../../../../api/client'
import { useLegModeActions } from '../../../../components/Planner/useLegModeActions'
import { toDisplayTemp } from '../../../../components/Planner/dayDetailModel'
import { getDayBookendHotels } from '../../../../utils/dayOrder'
@@ -170,7 +170,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
await tripActions.updateReservation(tripId, rid, { metadata: newMeta as unknown as string })
}
if (assignmentIds.length) await tripActions.reorderAssignments(tripId, dayId, assignmentIds)
- if (transportUpdates.length) await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
+ if (transportUpdates.length) await tripActions.updateReservationPositions(tripId, transportUpdates, dayId)
for (const n of noteUpdates) {
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
diff --git a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx
index 3587e560e..779172449 100644
--- a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx
@@ -37,7 +37,7 @@ const EMPTY_FORM: HotelForm = { check_in: '', check_in_end: '', check_out: '', c
* HotelPickerModal (day-plan panel). Marks one of the trip's places as a stay
* across a day range, with check-in/until/check-out times and a confirmation
* code. Opened from the day sheet's "Add accommodation" button; saving goes
- * through accommodationsApi and refreshes the planner's tripAccommodations.
+ * through accommodationRepo and refreshes the planner's tripAccommodations.
*/
export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps) {
const { t, locale } = useTranslation()
diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx
index e6cc32c85..7c9eb9f2c 100644
--- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx
@@ -9,7 +9,7 @@ import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import MBookingFilesCosts from './MBookingFilesCosts'
-import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel'
+import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel'
import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent'
import {
RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate,
@@ -144,6 +144,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
const handleSubmit = async () => {
// Only the costs button reaches this without the footer's date check.
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
+ if (filesNeedConnection(pendingFiles, canUploadFiles)) { toast.error(t('reservations.filesNeedConnection')); return }
const withExpense = expense.take()
setIsSaving(true)
try {
diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx
index 38f84fe6b..4b13119a0 100644
--- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx
@@ -16,7 +16,7 @@ import {
transportDayOptions, type StationWaypointForm, type WaypointForm,
} from '../../../../components/Planner/transportEndpoints'
import { useTransportForm } from '../../../../components/Planner/useTransportForm'
-import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel'
+import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel'
import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import MBookingFilesCosts from './MBookingFilesCosts'
@@ -123,6 +123,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
const handleSubmit = async () => {
if (!form.title.trim() || isSaving) return
+ if (filesNeedConnection(pendingFiles, canUploadFiles)) { toast.error(t('reservations.filesNeedConnection')); return }
const withExpense = expense.take()
setIsSaving(true)
try {
diff --git a/client/src/pages/tripPlanner/useBookingEdits.test.ts b/client/src/pages/tripPlanner/useBookingEdits.test.ts
index 1055dd90b..e16391b3a 100644
--- a/client/src/pages/tripPlanner/useBookingEdits.test.ts
+++ b/client/src/pages/tripPlanner/useBookingEdits.test.ts
@@ -183,14 +183,18 @@ describe('useBookingEdits', () => {
expect(actions.addPlace).not.toHaveBeenCalled()
})
- it('FE-TP-BOOKING-006: offline nothing is linked, and a failed search still makes the place without coordinates', async () => {
+ it('FE-TP-BOOKING-006: offline the stay is linked to a place made here, and a failed search still makes the place without coordinates', async () => {
const { result } = renderEdits()
+ // Offline the booking waits in the queue behind the place, so a place made
+ // or found offline is linked and goes out with its real id later.
setForcedOffline(true)
const offline = await saveHotel(result, { venue: { name: 'Lodge', address: 'Main 1' } })
- expect(offline.place_id).toBeUndefined()
+ expect(offline.place_id).toBe(900)
expect(mapsApi.search).not.toHaveBeenCalled()
- expect(actions.addPlace).not.toHaveBeenCalled()
+ expect(actions.addPlace).toHaveBeenCalledWith(42, { name: 'Lodge', lat: null, lng: null, address: 'Main 1' })
+ const hutOffline = await saveHotel(result, { venue: { name: 'Hut' } })
+ expect(hutOffline.place_id).toBe(-3)
_resetNetworkMode()
vi.mocked(mapsApi.search).mockRejectedValueOnce(new Error('overpass down'))
diff --git a/client/src/pages/tripPlanner/useBookingEdits.ts b/client/src/pages/tripPlanner/useBookingEdits.ts
index 3e8217371..52fe39c94 100644
--- a/client/src/pages/tripPlanner/useBookingEdits.ts
+++ b/client/src/pages/tripPlanner/useBookingEdits.ts
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { BookingImportPreviewItem } from '@trek/shared'
-import { accommodationsApi, mapsApi } from '../../api/client'
+import { mapsApi } from '../../api/client'
import { receiptToPrefill } from '../../components/Budget/CostsPanel.helpers'
import type { ExpensePrefill } from '../../components/Budget/CostsPanel'
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import { getImportFiles, deleteImportFiles } from '../../db/offlineDb'
+import { accommodationRepo } from '../../repo/accommodationRepo'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import type { TripStoreState } from '../../store/tripStore'
import { isEffectivelyOffline } from '../../sync/networkMode'
@@ -88,7 +89,7 @@ export function useBookingEdits(options: BookingEditsOptions) {
setShowReservationModal(false)
setEditingReservation(null)
if (data.type === 'hotel') {
- accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
+ accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r
} else {
@@ -100,7 +101,7 @@ export function useBookingEdits(options: BookingEditsOptions) {
if ((data as Record).create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
// Refresh accommodations if hotel was created
if (data.type === 'hotel') {
- accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
+ accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
return r
}
@@ -134,7 +135,7 @@ export function useBookingEdits(options: BookingEditsOptions) {
await tripActions.deleteReservation(tripId, id)
toast.success(t('trip.toast.deleted'))
// Refresh accommodations in case a hotel booking was deleted
- accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
+ accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
}
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
@@ -148,24 +149,19 @@ export function useBookingEdits(options: BookingEditsOptions) {
if (n) {
const existing = places.find(p => p.name?.trim().toLowerCase() === n)
?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
- // Only a server-side id may be linked. A negative id is an offline temp id
- // (mutationQueue.nextTempId): the reservation write is online-only, the queue
- // rewrites temp ids in a URL but never inside another entity's body, and
- // day_accommodations.place_id carries a foreign key, so a temp id here is a
- // rolled-back insert and a 500 instead of a saved booking.
- if (existing && existing.id > 0) return existing.id
+ // A negative id is a place created offline (mutationQueue.nextTempId). Offline
+ // the booking waits in the queue behind that place and is sent with its real
+ // id. Online the booking goes out at once, and day_accommodations.place_id
+ // carries a foreign key, so a temp id there is a 500 instead of a saved booking.
+ if (existing && (existing.id > 0 || isEffectivelyOffline())) return existing.id
}
- // Offline the booking itself cannot be written (reservations are online-only),
- // so minting a place here would only leave an orphan behind on the next flush,
- // and its temp id could never be linked anyway. Link nothing, and skip the
- // geocode round-trip too; the retry online matches this venue by name.
- if (isEffectivelyOffline()) return null
let lat: number | null = null
let lng: number | null = null
let address: string | null = venue.address ?? null
try {
const query = venue.address ? `${name} ${venue.address}`.trim() : name
- if (query) {
+ // Offline there is no geocoder to ask; the place is created without coords.
+ if (query && !isEffectivelyOffline()) {
const res = await mapsApi.search(query)
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
if (hit && hit.lat != null && hit.lng != null) {
@@ -182,7 +178,7 @@ export function useBookingEdits(options: BookingEditsOptions) {
// find it. addPlace unwraps the response and puts the place into
// `places`, so the next save reuses it.
const place = await tripActions.addPlace(tripId, { name: name || address || 'Accommodation', lat, lng, address })
- return place && place.id > 0 ? place.id : null
+ return place && (place.id > 0 || isEffectivelyOffline()) ? place.id : null
} catch { return null }
}
@@ -281,7 +277,7 @@ export function useBookingEdits(options: BookingEditsOptions) {
setImportReviewActive(false)
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
- accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
+ accommodationRepo.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
// Imported bookings auto-create their linked costs server-side, but the saving client
// suppresses its own budget:created echo (X-Socket-Id), so reload the budget items here
// to surface those expenses without a manual page refresh.
diff --git a/client/src/pages/tripPlanner/useRoadtripStopEdits.ts b/client/src/pages/tripPlanner/useRoadtripStopEdits.ts
index e2308a4cc..eb2a6d572 100644
--- a/client/src/pages/tripPlanner/useRoadtripStopEdits.ts
+++ b/client/src/pages/tripPlanner/useRoadtripStopEdits.ts
@@ -1,10 +1,10 @@
import { useCallback, useState } from 'react'
import type { RoadtripStopType } from '@trek/shared'
-import { accommodationsApi } from '../../api/client'
import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal'
import { reanchorAfterInsert, reanchorAfterRemove, reanchorAfterReorder } from '../../components/Roadtrip/roadtripModel'
import type { RoadtripStop } from '../../components/Roadtrip/useRoadtripRoutes'
import type { useRouteCalculation } from '../../hooks/useRouteCalculation'
+import { accommodationRepo } from '../../repo/accommodationRepo'
import { applyStayStops } from '../../store/stayStops'
import type { TripStoreState } from '../../store/tripStore'
import type { Accommodation } from '../../types'
@@ -67,7 +67,7 @@ export function useRoadtripStopEdits(options: RoadtripStopEditsOptions) {
if (accommodationId) {
// The night is what was switched off, not the stop: it stays where it is
// in the drive and becomes an ordinary pause.
- applyStayStops(await accommodationsApi.delete(tripId, accommodationId, { keepStop: true }))
+ applyStayStops(await accommodationRepo.delete(tripId, accommodationId, { keepStop: true }))
await loadAccommodations()
}
updateRouteForDay(dayId)
@@ -130,8 +130,8 @@ export function useRoadtripStopEdits(options: RoadtripStopEditsOptions) {
// it in the one person whose rail still shows the night at its old place in
// the chain is the one who just moved its check-in.
const written = stopDraft.editing.accommodationId
- ? await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking)
- : await accommodationsApi.create(tripId, booking)
+ ? await accommodationRepo.update(tripId, stopDraft.editing.accommodationId, booking)
+ : await accommodationRepo.create(tripId, booking)
applyStayStops(written)
// A night seated by a new check-in renumbers its neighbours as well, and the
// answer names only the night. The day is read back whole rather than guessed.
@@ -159,7 +159,7 @@ export function useRoadtripStopEdits(options: RoadtripStopEditsOptions) {
viaLiesBefore(dayId, { lat: poi.lat, lng: poi.lng }),
)
await tripActions.assignPlaceToDay(tripId, dayId, place.id, position)
- await accommodationsApi.create(tripId, {
+ await accommodationRepo.create(tripId, {
place_id: place.id,
start_day_id: dayId,
end_day_id: endDayId,
diff --git a/client/src/repo/accommodationRepo.offline.test.ts b/client/src/repo/accommodationRepo.offline.test.ts
new file mode 100644
index 000000000..2741302bc
--- /dev/null
+++ b/client/src/repo/accommodationRepo.offline.test.ts
@@ -0,0 +1,126 @@
+// FE-REPO-ACC-OFF-001 to FE-REPO-ACC-004
+// Offline write paths (#2601), accommodationRepo.test.ts covers the reads.
+import 'fake-indexeddb/auto';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { buildPlace } from '../../tests/helpers/factories';
+import { server } from '../../tests/helpers/msw/server';
+import type { QueuedMutation } from '../db/offlineDb';
+import { clearAll, offlineDb } from '../db/offlineDb';
+import { setAuthed } from '../sync/authGate';
+import { mutationQueue } from '../sync/mutationQueue';
+import { accommodationRepo } from './accommodationRepo';
+
+function setOnline(v: boolean): void {
+ Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true });
+}
+
+async function queue(): Promise {
+ return (await offlineDb.mutationQueue.toArray()).sort((a, b) => a.createdAt - b.createdAt);
+}
+
+beforeEach(async () => {
+ await clearAll();
+ mutationQueue._resetFlushing();
+ setOnline(false);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ setAuthed(false);
+ setOnline(true);
+});
+
+describe('accommodationRepo offline writes', () => {
+ it('FE-REPO-ACC-OFF-001: a new stay gets a temp row with its place and a queued POST', async () => {
+ await offlineDb.places.put(
+ buildPlace({ id: 40, trip_id: 4, name: 'Camping Nord', address: 'Seeweg 1', lat: 1, lng: 2 })
+ );
+ const body = { place_id: 40, start_day_id: '11', end_day_id: 12, check_in: '15:00' };
+
+ const result = await accommodationRepo.create(4, body);
+
+ expect(result.accommodation.id).toBeLessThan(0);
+ expect(result.accommodation).toMatchObject({
+ trip_id: 4,
+ place_id: 40,
+ start_day_id: 11,
+ end_day_id: 12,
+ check_in: '15:00',
+ place_name: 'Camping Nord',
+ place_address: 'Seeweg 1',
+ place_lat: 1,
+ place_lng: 2,
+ });
+ expect(result.assignment).toBeUndefined();
+ expect(await offlineDb.accommodations.get(result.accommodation.id)).toMatchObject({ place_id: 40 });
+ expect((await queue())[0]).toMatchObject({
+ method: 'POST',
+ url: '/trips/4/accommodations',
+ resource: 'accommodations',
+ tempId: result.accommodation.id,
+ body,
+ });
+ });
+
+ it('FE-REPO-ACC-OFF-002: an edit and a delete of an unsynced stay wait for its id, keepStop rides along', async () => {
+ const { accommodation } = await accommodationRepo.create(4, { place_id: 40, start_day_id: 11, end_day_id: 12 });
+ const { accommodation: edited } = await accommodationRepo.update(4, accommodation.id, { confirmation: 'AB12' });
+ expect(edited).toMatchObject({ id: accommodation.id, place_id: 40, confirmation: 'AB12' });
+ await accommodationRepo.delete(4, accommodation.id, { keepStop: true });
+
+ expect(await offlineDb.accommodations.get(accommodation.id)).toBeUndefined();
+ const [, edit, del] = await queue();
+ expect(edit).toMatchObject({ method: 'PUT', url: '/trips/4/accommodations/{id}', tempEntityId: accommodation.id });
+ expect(del).toMatchObject({
+ method: 'DELETE',
+ url: '/trips/4/accommodations/{id}?keepStop=true',
+ tempEntityId: accommodation.id,
+ });
+ });
+
+ it('FE-REPO-ACC-OFF-003: a replayed stay moves to the real id and the queued edit follows it', async () => {
+ const { accommodation } = await accommodationRepo.create(4, { place_id: 40, start_day_id: 11, end_day_id: 12 });
+ await accommodationRepo.update(4, accommodation.id, { notes: 'Late arrival' });
+ const puts: string[] = [];
+ server.use(
+ http.post('/api/trips/4/accommodations', () =>
+ HttpResponse.json({
+ accommodation: { id: 700, trip_id: 4, place_id: 40, start_day_id: 11, end_day_id: 12 },
+ assignment: null,
+ })
+ ),
+ http.put('/api/trips/4/accommodations/:id', ({ params }) => {
+ puts.push(String(params.id));
+ return HttpResponse.json({
+ accommodation: { id: 700, trip_id: 4, place_id: 40, start_day_id: 11, end_day_id: 12, notes: 'Late arrival' },
+ });
+ })
+ );
+ setAuthed(true);
+ setOnline(true);
+
+ await mutationQueue.flush();
+
+ expect(puts).toEqual(['700']);
+ expect(await offlineDb.mutationQueue.count()).toBe(0);
+ expect(await offlineDb.accommodations.get(accommodation.id)).toBeUndefined();
+ expect(await offlineDb.accommodations.get(700)).toMatchObject({ notes: 'Late arrival' });
+ });
+
+ it('FE-REPO-ACC-OFF-004: online, a delete goes straight out and hands back the stops it moved', async () => {
+ setOnline(true);
+ await offlineDb.accommodations.put({ id: 41, trip_id: 4, place_id: 40, start_day_id: 11, end_day_id: 12 });
+ server.use(
+ http.delete('/api/trips/4/accommodations/41', () =>
+ HttpResponse.json({ success: true, removedAssignments: [{ id: 5, dayId: 11 }], updatedAssignments: [] })
+ )
+ );
+
+ const result = await accommodationRepo.delete(4, 41);
+
+ expect(result.removedAssignments).toEqual([{ id: 5, dayId: 11 }]);
+ expect(await offlineDb.accommodations.get(41)).toBeUndefined();
+ expect(await offlineDb.mutationQueue.count()).toBe(0);
+ });
+});
diff --git a/client/src/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts
index 8713ad4aa..65fbf1e81 100644
--- a/client/src/repo/accommodationRepo.ts
+++ b/client/src/repo/accommodationRepo.ts
@@ -1,8 +1,76 @@
+import type { AccommodationCreateRequest, AccommodationUpdateRequest } from '@trek/shared'
import { accommodationsApi } from '../api/client'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
+import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
+import { isEffectivelyOffline } from '../sync/networkMode'
import { onlineThenCache } from './withOfflineFallback'
-import type { Accommodation } from '../types'
+import type { Accommodation, Assignment } from '../types'
+/** The day-plan half of what an accommodation write answers with. */
+export interface StayStopsResult {
+ assignment?: Assignment | null
+ /** The booking's own stop, carried to the day the booking now covers. */
+ movedAssignment?: { assignment: Assignment; oldDayId: number } | null
+ /** Stops the booking let go of but left standing (a night dropped, the place kept). */
+ updatedAssignments?: Assignment[]
+ removedAssignments?: { id: number; dayId: number }[]
+}
+
+/** What a stay write answers with: the stay, and the day stops it moved. */
+export type StayWriteResult = StayStopsResult & { accommodation: Accommodation }
+
+const STAY_FIELDS = ['place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out', 'confirmation', 'notes'] as const
+
+/**
+ * The stay an offline write leaves on screen until the queue replays it. The
+ * place fields the server joins in come from the cached place. The day stop
+ * the server seats for a new night arrives with the next read once it is sent.
+ */
+async function localStay(id: number, tripId: number, body: AccommodationUpdateRequest, before: Accommodation | undefined): Promise {
+ const row: Record = { ...(before ?? {}), id, trip_id: tripId }
+ for (const field of STAY_FIELDS) {
+ if (body[field] === undefined) continue
+ const value = body[field]
+ row[field] = field.endsWith('_id') && value != null ? Number(value) : value
+ }
+ if (row.place_id != null && row.place_id !== before?.place_id) {
+ const place = await offlineDb.places.get(Number(row.place_id))
+ Object.assign(row, {
+ place_name: place?.name ?? null,
+ place_address: place?.address ?? null,
+ place_image: place?.image_url ?? null,
+ place_lat: place?.lat ?? null,
+ place_lng: place?.lng ?? null,
+ })
+ }
+ return row as Accommodation
+}
+
+/** Queue a write to one stay, held behind any older write to it. */
+async function enqueueStayWrite(tripId: number | string, id: number, method: 'PUT' | 'DELETE', url: string, body: unknown): Promise {
+ await mutationQueue.enqueue({
+ id: generateUUID(),
+ tripId: Number(tripId),
+ method,
+ url,
+ body,
+ resource: 'accommodations',
+ entityId: id,
+ ...(id < 0 ? { tempEntityId: id } : {}),
+ })
+ mutationQueue.sendSoon()
+}
+
+function stayUrl(tripId: number | string, id: number): string {
+ return `/trips/${tripId}/accommodations/${id < 0 ? '{id}' : id}`
+}
+
+/**
+ * Stays (booked nights) through the offline core, the way bookings go: reads
+ * fall back to the Dexie copy, a write made offline lands there and in the
+ * mutation queue, and an online write to a stay with an older one still queued
+ * joins the queue behind it, since stay writes carry no version token.
+ */
export const accommodationRepo = {
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
return onlineThenCache(
@@ -17,4 +85,52 @@ export const accommodationRepo = {
}),
)
},
+
+ async create(tripId: number | string, data: AccommodationCreateRequest): Promise {
+ if (!isEffectivelyOffline()) {
+ const result: StayWriteResult = await accommodationsApi.create(tripId, data)
+ if (result?.accommodation) await offlineDb.accommodations.put(result.accommodation)
+ return result
+ }
+ const tid = Number(tripId)
+ const tempId = nextTempId()
+ const accommodation = await localStay(tempId, tid, data, undefined)
+ await offlineDb.transaction('rw', offlineDb.accommodations, offlineDb.mutationQueue, async () => {
+ await offlineDb.accommodations.put(accommodation)
+ await mutationQueue.enqueue({
+ id: generateUUID(),
+ tripId: tid,
+ method: 'POST',
+ url: `/trips/${tripId}/accommodations`,
+ body: data,
+ resource: 'accommodations',
+ tempId,
+ })
+ })
+ return { accommodation }
+ },
+
+ async update(tripId: number | string, id: number, data: AccommodationUpdateRequest): Promise {
+ if (!(await mutationQueue.mustQueue('accommodations', id))) {
+ const result: StayWriteResult = await accommodationsApi.update(tripId, id, data)
+ if (result?.accommodation) await offlineDb.accommodations.put(result.accommodation)
+ return result
+ }
+ const accommodation = await localStay(id, Number(tripId), data, await offlineDb.accommodations.get(id))
+ await offlineDb.accommodations.put(accommodation)
+ await enqueueStayWrite(tripId, id, 'PUT', stayUrl(tripId, id), data)
+ return { accommodation }
+ },
+
+ /** `keepStop` leaves the night's stop on the day as an ordinary pause (road trip mode). */
+ async delete(tripId: number | string, id: number, opts?: { keepStop?: boolean }): Promise {
+ let result: StayStopsResult = {}
+ if (!(await mutationQueue.mustQueue('accommodations', id))) {
+ result = await accommodationsApi.delete(tripId, id, opts)
+ } else {
+ await enqueueStayWrite(tripId, id, 'DELETE', `${stayUrl(tripId, id)}${opts?.keepStop ? '?keepStop=true' : ''}`, undefined)
+ }
+ await offlineDb.accommodations.delete(id)
+ return result
+ },
}
diff --git a/client/src/repo/reservationRepo.offline.test.ts b/client/src/repo/reservationRepo.offline.test.ts
new file mode 100644
index 000000000..580b23a03
--- /dev/null
+++ b/client/src/repo/reservationRepo.offline.test.ts
@@ -0,0 +1,181 @@
+// FE-REPO-RESV-OFF-001 to FE-REPO-RESV-007
+// Offline write paths (#2601), reservationRepo.test.ts covers the reads.
+import 'fake-indexeddb/auto';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { buildBudgetItem, buildPlace, buildReservation } from '../../tests/helpers/factories';
+import { server } from '../../tests/helpers/msw/server';
+import type { QueuedMutation } from '../db/offlineDb';
+import { clearAll, offlineDb } from '../db/offlineDb';
+import { setAuthed } from '../sync/authGate';
+import { mutationQueue } from '../sync/mutationQueue';
+import { reservationRepo } from './reservationRepo';
+
+function setOnline(v: boolean): void {
+ Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true });
+}
+
+async function queue(): Promise {
+ return (await offlineDb.mutationQueue.toArray()).sort((a, b) => a.createdAt - b.createdAt);
+}
+
+beforeEach(async () => {
+ await clearAll();
+ mutationQueue._resetFlushing();
+ setOnline(false);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ setAuthed(false);
+ setOnline(true);
+});
+
+describe('reservationRepo offline writes', () => {
+ it('FE-REPO-RESV-OFF-001: a new booking gets a temp row and a queued POST with the body as sent', async () => {
+ await offlineDb.places.put(buildPlace({ id: 30, trip_id: 4, name: 'Hotel Adler' }));
+ const body = {
+ title: 'Hotel Adler',
+ type: 'hotel',
+ place_id: 30,
+ metadata: { check_in_time: '15:00' },
+ create_accommodation: { place_id: 30, start_day_id: 1, end_day_id: 2 },
+ };
+
+ const { reservation } = await reservationRepo.create(4, body);
+
+ expect(reservation.id).toBeLessThan(0);
+ expect(reservation).toMatchObject({
+ trip_id: 4,
+ title: 'Hotel Adler',
+ type: 'hotel',
+ status: 'pending',
+ place_name: 'Hotel Adler',
+ metadata: '{"check_in_time":"15:00"}',
+ });
+ expect(reservation).not.toHaveProperty('create_accommodation');
+ expect(await offlineDb.reservations.get(reservation.id)).toMatchObject({ title: 'Hotel Adler' });
+ const [mutation] = await queue();
+ expect(mutation).toMatchObject({
+ method: 'POST',
+ url: '/trips/4/reservations',
+ resource: 'reservations',
+ tempId: reservation.id,
+ body,
+ });
+ });
+
+ it('FE-REPO-RESV-OFF-002: an edit, the travelers and a delete of an unsynced booking wait for its id', async () => {
+ await offlineDb.tripMembers.put({ tripId: 4, id: 7, username: 'lena', avatar_url: null } as never);
+ const { reservation } = await reservationRepo.create(4, { title: 'Train' });
+ const { reservation: edited } = await reservationRepo.update(4, reservation.id, { status: 'confirmed' });
+ expect(edited).toMatchObject({ id: reservation.id, title: 'Train', status: 'confirmed' });
+ const { travelers } = await reservationRepo.setTravelers(4, reservation.id, [7]);
+ expect(travelers).toEqual([{ user_id: 7, username: 'lena', avatar_url: null }]);
+ await reservationRepo.delete(4, reservation.id);
+
+ expect(await offlineDb.reservations.get(reservation.id)).toBeUndefined();
+ const [, edit, travel, del] = await queue();
+ expect(edit).toMatchObject({
+ method: 'PUT',
+ url: '/trips/4/reservations/{id}',
+ tempEntityId: reservation.id,
+ entityId: reservation.id,
+ });
+ expect(travel).toMatchObject({
+ method: 'PUT',
+ url: '/trips/4/reservations/{id}/travelers',
+ body: { user_ids: [7] },
+ });
+ expect(del).toMatchObject({ method: 'DELETE', url: '/trips/4/reservations/{id}', tempEntityId: reservation.id });
+ });
+
+ it('FE-REPO-RESV-OFF-003: an edit of a synced booking keeps the fields it does not name', async () => {
+ await offlineDb.reservations.put(
+ buildReservation({ id: 20, trip_id: 4, title: 'Museum', notes: 'Bring ID', status: 'pending' })
+ );
+
+ const { reservation } = await reservationRepo.update(4, 20, { status: 'confirmed' });
+
+ expect(reservation).toMatchObject({ id: 20, title: 'Museum', notes: 'Bring ID', status: 'confirmed' });
+ const [mutation] = await queue();
+ expect(mutation).toMatchObject({
+ method: 'PUT',
+ url: '/trips/4/reservations/20',
+ entityId: 20,
+ body: { status: 'confirmed' },
+ });
+ expect(mutation.tempEntityId).toBeUndefined();
+ });
+
+ it('FE-REPO-RESV-OFF-004: deleting a booking offline also drops its linked costs from the cache', async () => {
+ await offlineDb.reservations.put(buildReservation({ id: 21, trip_id: 4 }));
+ await offlineDb.budgetItems.bulkPut([
+ buildBudgetItem({ id: 300, trip_id: 4, reservation_id: 21 }),
+ buildBudgetItem({ id: 301, trip_id: 4, reservation_id: null }),
+ ]);
+
+ await reservationRepo.delete(4, 21);
+
+ expect(await offlineDb.reservations.get(21)).toBeUndefined();
+ expect((await offlineDb.budgetItems.toArray()).map((i) => i.id)).toEqual([301]);
+ expect((await queue())[0]).toMatchObject({ method: 'DELETE', url: '/trips/4/reservations/21' });
+ });
+
+ it('FE-REPO-RESV-OFF-005: transport positions for a day are written here and queued without temp ids', async () => {
+ await offlineDb.reservations.put(buildReservation({ id: 22, trip_id: 4, day_positions: { 5: 1 } }));
+
+ await reservationRepo.updatePositions(
+ 4,
+ [
+ { id: 22, day_plan_position: 2.5 },
+ { id: -9, day_plan_position: 3 },
+ ],
+ 6
+ );
+
+ expect(await offlineDb.reservations.get(22)).toMatchObject({
+ day_plan_position: 2.5,
+ day_positions: { 5: 1, 6: 2.5 },
+ });
+ expect((await queue())[0]).toMatchObject({
+ method: 'PUT',
+ url: '/trips/4/reservations/positions',
+ body: { positions: [{ id: 22, day_plan_position: 2.5 }], day_id: 6 },
+ });
+ });
+
+ it('FE-REPO-RESV-OFF-006: online, an edit of a booking with an older queued write joins the queue', async () => {
+ await offlineDb.reservations.put(buildReservation({ id: 23, trip_id: 4, title: 'Ferry' }));
+ await reservationRepo.update(4, 23, { title: 'Ferry 1' });
+ setOnline(true);
+ setAuthed(false);
+ const direct = vi.fn();
+ server.use(
+ http.put('/api/trips/4/reservations/23', () => {
+ direct();
+ return HttpResponse.json({});
+ })
+ );
+
+ await reservationRepo.update(4, 23, { title: 'Ferry 2' });
+
+ expect(direct).not.toHaveBeenCalled();
+ expect((await queue()).map((m) => (m.body as { title: string }).title)).toEqual(['Ferry 1', 'Ferry 2']);
+ });
+
+ it('FE-REPO-RESV-OFF-007: online, a new booking goes straight out and is cached', async () => {
+ setOnline(true);
+ server.use(
+ http.post('/api/trips/4/reservations', () =>
+ HttpResponse.json({ reservation: buildReservation({ id: 50, trip_id: 4, title: 'Dinner' }) })
+ )
+ );
+
+ const { reservation } = await reservationRepo.create(4, { title: 'Dinner' });
+
+ expect(reservation.id).toBe(50);
+ expect(await offlineDb.reservations.get(50)).toMatchObject({ title: 'Dinner' });
+ expect(await offlineDb.mutationQueue.count()).toBe(0);
+ });
+});
diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts
index b34544dc3..2f9ddbdda 100644
--- a/client/src/repo/reservationRepo.ts
+++ b/client/src/repo/reservationRepo.ts
@@ -1,8 +1,71 @@
+import type { ReservationCreateRequest, ReservationTraveler, ReservationUpdateRequest } from '@trek/shared'
import { reservationsApi } from '../api/client'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
+import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
+import { isEffectivelyOffline } from '../sync/networkMode'
+import { withTransportPositions } from '../utils/mergedOrder'
import { onlineThenCache } from './withOfflineFallback'
import type { Reservation } from '../types'
+type ReservationBody = ReservationCreateRequest | ReservationUpdateRequest
+
+export interface ReservationPosition {
+ id: number
+ day_plan_position: number
+}
+
+// Fields a booking write may carry that are instructions to the server, not
+// columns of the row: the stay and the cost it creates alongside, and the legs
+// it stores as endpoints.
+const REQUEST_ONLY_FIELDS = ['create_accommodation', 'create_budget_entry', 'endpoints'] as const
+
+/**
+ * The booking an offline write leaves on screen until the queue replays it and
+ * the server's own row replaces it. Metadata is kept as the JSON text the server
+ * answers with, and the joined day and place names come from the cached rows.
+ */
+async function localReservation(id: number, tripId: number, body: ReservationBody, before: Reservation | undefined): Promise {
+ const fields: Record = { ...body }
+ for (const key of REQUEST_ONLY_FIELDS) delete fields[key]
+ if (fields.metadata !== undefined && fields.metadata !== null && typeof fields.metadata !== 'string') {
+ fields.metadata = JSON.stringify(fields.metadata)
+ }
+ const row = {
+ ...(before ?? { status: 'pending', type: 'other', travelers: [], endpoints: [] }),
+ ...fields,
+ id,
+ trip_id: tripId,
+ } as Reservation
+ if (row.day_id) row.day_number = (await offlineDb.days.get(Number(row.day_id)))?.day_number ?? row.day_number ?? null
+ if (row.place_id && row.place_id !== before?.place_id) row.place_name = (await offlineDb.places.get(Number(row.place_id)))?.name ?? null
+ return row
+}
+
+/** Queue a write to one booking, held behind any older write to it. */
+async function enqueueReservationWrite(tripId: number | string, id: number, method: 'PUT' | 'DELETE', suffix: string, body: unknown): Promise {
+ await mutationQueue.enqueue({
+ id: generateUUID(),
+ tripId: Number(tripId),
+ method,
+ url: `/trips/${tripId}/reservations/${id < 0 ? '{id}' : id}${suffix}`,
+ body,
+ resource: 'reservations',
+ entityId: id,
+ ...(id < 0 ? { tempEntityId: id } : {}),
+ })
+ mutationQueue.sendSoon()
+}
+
+/**
+ * Bookings and transports through the offline core. Reads fall back to the
+ * Dexie copy; a write made offline lands there and in the mutation queue and
+ * replays once the connection is back. Booking writes carry no version token,
+ * so an online write to a booking that still has an older one queued joins the
+ * queue behind it (mustQueue).
+ *
+ * What the server does alongside a booking (the stay of a hotel, the linked
+ * cost) happens when the queue sends it, and shows with the next read.
+ */
export const reservationRepo = {
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
return onlineThenCache(
@@ -17,4 +80,95 @@ export const reservationRepo = {
}),
)
},
+
+ async create(tripId: number | string, data: ReservationCreateRequest): Promise<{ reservation: Reservation }> {
+ if (!isEffectivelyOffline()) {
+ const result: { reservation: Reservation } = await reservationsApi.create(tripId, data)
+ if (result?.reservation) await offlineDb.reservations.put(result.reservation)
+ return result
+ }
+ const tid = Number(tripId)
+ const tempId = nextTempId()
+ const reservation = await localReservation(tempId, tid, data, undefined)
+ await offlineDb.transaction('rw', offlineDb.reservations, offlineDb.mutationQueue, async () => {
+ await offlineDb.reservations.put(reservation)
+ await mutationQueue.enqueue({
+ id: generateUUID(),
+ tripId: tid,
+ method: 'POST',
+ url: `/trips/${tripId}/reservations`,
+ body: data,
+ resource: 'reservations',
+ tempId,
+ })
+ })
+ return { reservation }
+ },
+
+ async update(tripId: number | string, id: number, data: ReservationUpdateRequest): Promise<{ reservation: Reservation }> {
+ if (!(await mutationQueue.mustQueue('reservations', id))) {
+ const result: { reservation: Reservation } = await reservationsApi.update(tripId, id, data)
+ if (result?.reservation) await offlineDb.reservations.put(result.reservation)
+ return result
+ }
+ const reservation = await localReservation(id, Number(tripId), data, await offlineDb.reservations.get(id))
+ await offlineDb.reservations.put(reservation)
+ await enqueueReservationWrite(tripId, id, 'PUT', '', data)
+ return { reservation }
+ },
+
+ /** Deleting a booking takes its linked costs with it, here as on the server. */
+ async delete(tripId: number | string, id: number): Promise {
+ if (!(await mutationQueue.mustQueue('reservations', id))) {
+ await reservationsApi.delete(tripId, id)
+ } else {
+ await enqueueReservationWrite(tripId, id, 'DELETE', '', undefined)
+ }
+ await offlineDb.reservations.delete(id)
+ await offlineDb.budgetItems.where('trip_id').equals(Number(tripId)).filter(i => i.reservation_id === id).delete()
+ },
+
+ async setTravelers(tripId: number | string, id: number, userIds: number[]): Promise<{ travelers: ReservationTraveler[] }> {
+ if (!(await mutationQueue.mustQueue('reservations', id))) {
+ const result: { travelers: ReservationTraveler[] } = await reservationsApi.setTravelers(tripId, id, userIds)
+ await offlineDb.reservations.where('id').equals(id).modify(r => { r.travelers = result.travelers })
+ return result
+ }
+ const members = await offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
+ const travelers: ReservationTraveler[] = userIds.map(user_id => {
+ const m = members.find(x => x.id === user_id)
+ return { user_id, username: m?.username ?? '', avatar_url: m?.avatar_url ?? null }
+ })
+ await offlineDb.reservations.where('id').equals(id).modify(r => { r.travelers = travelers })
+ await enqueueReservationWrite(tripId, id, 'PUT', '/travelers', { user_ids: userIds })
+ return { travelers }
+ },
+
+ /**
+ * Where the transports sit in a day's plan. Offline the order waits in the
+ * queue; a booking created offline has no server id to name yet, so it keeps
+ * the place the server gives it.
+ */
+ async updatePositions(tripId: number | string, positions: ReservationPosition[], dayId?: number): Promise {
+ const synced = positions.filter(p => p.id > 0)
+ if (!isEffectivelyOffline()) {
+ if (synced.length) await reservationsApi.updatePositions(tripId, synced, dayId)
+ return
+ }
+ await offlineDb.transaction('rw', offlineDb.reservations, offlineDb.mutationQueue, async () => {
+ const rows = await offlineDb.reservations.bulkGet(positions.map(p => p.id))
+ const present = rows.filter((r): r is Reservation => r !== undefined)
+ const moved = dayId === undefined
+ ? present.map(r => ({ ...r, day_plan_position: positions.find(p => p.id === r.id)?.day_plan_position ?? r.day_plan_position }))
+ : withTransportPositions(present, positions, dayId)
+ await offlineDb.reservations.bulkPut(moved)
+ await mutationQueue.enqueue({
+ id: generateUUID(),
+ tripId: Number(tripId),
+ method: 'PUT',
+ url: `/trips/${tripId}/reservations/positions`,
+ body: { positions: synced, day_id: dayId },
+ })
+ })
+ },
}
diff --git a/client/src/store/slices/reservationsSlice.ts b/client/src/store/slices/reservationsSlice.ts
index e36ed0f88..fadf6edf1 100644
--- a/client/src/store/slices/reservationsSlice.ts
+++ b/client/src/store/slices/reservationsSlice.ts
@@ -1,5 +1,4 @@
-import { reservationsApi } from '../../api/client'
-import { reservationRepo } from '../../repo/reservationRepo'
+import { reservationRepo, type ReservationPosition } from '../../repo/reservationRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Reservation } from '../../types'
@@ -16,6 +15,7 @@ export interface ReservationsSlice {
toggleReservationStatus: (tripId: number | string, id: number) => Promise
deleteReservation: (tripId: number | string, id: number) => Promise
setReservationTravelers: (tripId: number | string, id: number, userIds: number[]) => Promise
+ updateReservationPositions: (tripId: number | string, positions: ReservationPosition[], dayId?: number) => Promise
}
export const createReservationsSlice = (set: SetState, get: GetState): ReservationsSlice => ({
@@ -30,7 +30,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
addReservation: async (tripId, data) => {
try {
- const result = await reservationsApi.create(tripId, data)
+ const result = await reservationRepo.create(tripId, data)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err: unknown) {
@@ -40,7 +40,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
updateReservation: async (tripId, id, data) => {
try {
- const result = await reservationsApi.update(tripId, id, data)
+ const result = await reservationRepo.update(tripId, id, data)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? keepDayPositions(result.reservation, r) : r)
}))
@@ -59,7 +59,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
- await reservationsApi.update(tripId, id, { status: newStatus })
+ await reservationRepo.update(tripId, id, { status: newStatus })
} catch (err: unknown) {
// Roll back the optimistic toggle and surface the failure so the caller's
// catch can notify the user — without it the status silently snaps back.
@@ -70,7 +70,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
deleteReservation: async (tripId, id) => {
try {
- await reservationsApi.delete(tripId, id)
+ await reservationRepo.delete(tripId, id)
set(state => ({
reservations: state.reservations.filter(r => r.id !== id),
budgetItems: state.budgetItems.filter(i => i.reservation_id !== id),
@@ -82,7 +82,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
setReservationTravelers: async (tripId, id, userIds) => {
try {
- const result = await reservationsApi.setTravelers(tripId, id, userIds)
+ const result = await reservationRepo.setTravelers(tripId, id, userIds)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? { ...r, travelers: result.travelers } : r),
}))
@@ -90,4 +90,9 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
throw new Error(getApiErrorMessage(err, 'Error updating travelers'))
}
},
+
+ // The caller has already moved the transports in the store; this only stores it.
+ updateReservationPositions: async (tripId, positions, dayId) => {
+ await reservationRepo.updatePositions(tripId, positions, dayId)
+ },
})
diff --git a/client/src/store/stayStops.ts b/client/src/store/stayStops.ts
index d3b3da207..183ee572c 100644
--- a/client/src/store/stayStops.ts
+++ b/client/src/store/stayStops.ts
@@ -1,15 +1,7 @@
import { useTripStore } from './tripStore'
-import type { Assignment } from '../types'
+import type { StayStopsResult } from '../repo/accommodationRepo'
-/** The day-plan half of what an accommodation write answers with. */
-export interface StayStopsResult {
- assignment?: Assignment | null
- /** The booking's own stop, carried to the day the booking now covers. */
- movedAssignment?: { assignment: Assignment; oldDayId: number } | null
- /** Stops the booking let go of but left standing (a night dropped, the place kept). */
- updatedAssignments?: Assignment[]
- removedAssignments?: { id: number; dayId: number }[]
-}
+export type { StayStopsResult }
/**
* Fold the day stop a stay write reported back into the store.
diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts
index ed9e658fa..661fde557 100644
--- a/client/src/sync/mutationQueue.ts
+++ b/client/src/sync/mutationQueue.ts
@@ -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 } from '../types'
+import type { BudgetItem, BudgetItemMember, Reservation } from '../types'
// Map Dexie table names used in `resource` field → actual Dexie tables.
function getTable(resource: string): Table | undefined {
@@ -25,6 +25,7 @@ function getTable(resource: string): Table | undefined {
todoItems: offlineDb.todoItems,
budgetItems: offlineDb.budgetItems,
reservations: offlineDb.reservations,
+ accommodations: offlineDb.accommodations,
tripFiles: offlineDb.tripFiles,
}
return map[resource]
@@ -219,8 +220,12 @@ async function remapTempId(mutation: QueuedMutation, realId: number, idMap: Map<
}
// Body fields that can name an entity created offline earlier in the queue: an
-// expense saved from a place or a booking that has not synced yet.
-const TEMP_REF_FIELDS = ['place_id', 'reservation_id'] as const
+// expense saved from a place or a booking that has not synced yet, a booking
+// linked to such a place or stay.
+const TEMP_REF_FIELDS = ['place_id', 'reservation_id', 'accommodation_id'] as const
+// 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
/**
* The body with every link to an offline-created entity pointed at the real id
@@ -237,6 +242,13 @@ function remapBodyRefs(body: unknown, idMap: Map): unknown {
remapped ??= { ...fields }
remapped[field] = realId
}
+ for (const key of TEMP_REF_OBJECTS) {
+ const nested = fields[key]
+ const next = remapBodyRefs(nested, idMap)
+ if (next === nested) continue
+ remapped ??= { ...fields }
+ remapped[key] = next
+ }
return remapped ?? body
}
@@ -259,6 +271,24 @@ async function reconcileBudgetItem(mutation: QueuedMutation, data: unknown, idMa
await offlineDb.budgetItems.put({ ...local, ...item, ...(answer.members ? { members: answer.members } : {}) })
}
+/**
+ * A replayed booking write. Only `reservation` names the booking: the travelers
+ * route answers `{ travelers, reservation }`, which the first-value rule of the
+ * generic branch would read as the traveler list. The travelers the answer
+ * lists replace the local ones; a row that does not list them keeps them.
+ */
+async function reconcileReservation(mutation: QueuedMutation, data: unknown, idMap: Map): Promise {
+ const answer = (data && typeof data === 'object' ? data : {}) as { reservation?: Reservation; travelers?: Reservation['travelers'] }
+ const reservation = answer.reservation
+ if (!reservation || typeof reservation.id !== 'number') return
+ if (mutation.tempId !== undefined && mutation.tempId !== reservation.id) {
+ await offlineDb.reservations.delete(mutation.tempId)
+ await remapTempId(mutation, reservation.id, idMap)
+ }
+ const local = await offlineDb.reservations.get(reservation.id)
+ await offlineDb.reservations.put({ ...local, ...reservation, ...(answer.travelers ? { travelers: answer.travelers } : {}) })
+}
+
/**
* 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
@@ -497,6 +527,8 @@ export const mutationQueue = {
await reconcileTour(mutation, tourCreateResponseSchema.parse(response.data), idMap)
} else if (mutation.resource === 'budgetItems' && mutation.method !== 'DELETE') {
await reconcileBudgetItem(mutation, response.data, idMap)
+ } else if (mutation.resource === 'reservations' && mutation.method !== 'DELETE') {
+ await reconcileReservation(mutation, response.data, idMap)
} else if (mutation.method !== 'DELETE' && mutation.resource) {
const table = getTable(mutation.resource)
if (table && response.data && typeof response.data === 'object') {
diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts
index 985c674c3..719d6a0c4 100644
--- a/client/tests/helpers/mobileTrip.ts
+++ b/client/tests/helpers/mobileTrip.ts
@@ -26,7 +26,7 @@ export function buildTripActions(): Record> {
'reorderDays', 'setAssignments', 'setPackedCount', 'setPackingItemSharing', 'setSelectedDay',
'toggleBudgetMemberPaid', 'togglePackingItem', 'toggleReservationStatus', 'toggleTodoItem',
'updateDayNote', 'updateDayTitle', 'updatePackingItem', 'updatePlace', 'updatePlacesMany',
- 'updateReservation', 'updateTodoItem', 'updateTrip', 'uploadPlaceImage',
+ 'updateReservation', 'updateReservationPositions', 'updateTodoItem', 'updateTrip', 'uploadPlaceImage',
];
const actions: Record> = {};
for (const name of names) actions[name] = vi.fn(async () => undefined);
diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
index 087f73dcf..d6c654790 100644
--- a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
+++ b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WeatherResult } from '@trek/shared'
-import { assignmentsApi, reservationsApi, weatherApi } from '../../../../src/api/client'
+import { assignmentsApi, weatherApi } from '../../../../src/api/client'
import { useMPlanTimeline } from '../../../../src/mobile/screens/trip/plan/useMPlanTimeline'
import type { TransportEntry } from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
@@ -94,7 +94,6 @@ describe('useMPlanTimeline', () => {
routeCalc.segments = []
vi.spyOn(weatherApi, 'get').mockResolvedValue(FORECAST)
vi.spyOn(assignmentsApi, 'updateTransport').mockResolvedValue({})
- vi.spyOn(reservationsApi, 'updatePositions').mockResolvedValue({})
vi.spyOn(window, 'open').mockImplementation(() => null)
})
@@ -352,7 +351,7 @@ describe('useMPlanTimeline', () => {
await act(async () => { result.current.moveRow(result.current.merged[3], 'up') })
- await waitFor(() => expect(reservationsApi.updatePositions).toHaveBeenCalledWith(
+ await waitFor(() => expect(planner.tripActions.updateReservationPositions).toHaveBeenCalledWith(
1, [{ id: 51, day_plan_position: 2 / 3 }], 2,
))
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 12])
@@ -433,7 +432,7 @@ describe('useMPlanTimeline', () => {
await act(async () => { result.current.moveRow(result.current.merged[2], 'up') })
- await waitFor(() => expect(reservationsApi.updatePositions).toHaveBeenCalledWith(
+ await waitFor(() => expect(planner.tripActions.updateReservationPositions).toHaveBeenCalledWith(
1, [{ id: 51, day_plan_position: 0.5 }], 2,
))
// Only the moved booking gains a per-day position; the other day's booking is untouched.
diff --git a/client/tests/unit/slices/reservationsSlice.test.ts b/client/tests/unit/slices/reservationsSlice.test.ts
index a95b91b77..feb96938f 100644
--- a/client/tests/unit/slices/reservationsSlice.test.ts
+++ b/client/tests/unit/slices/reservationsSlice.test.ts
@@ -1,9 +1,11 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
-import { buildReservation } from '../../helpers/factories';
+import { buildBudgetItem, buildReservation } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
+import { clearAll, offlineDb } from '../../../src/db/offlineDb';
+import { _resetNetworkMode, setForcedOffline } from '../../../src/sync/networkMode';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -179,4 +181,52 @@ describe('reservationsSlice', () => {
expect(useTripStore.getState().reservations).toHaveLength(1);
});
});
+ // #2601: offline a booking is kept here and queued instead of failing with a network error.
+ describe('offline', () => {
+ beforeEach(async () => {
+ await clearAll();
+ setForcedOffline(true);
+ });
+ afterEach(() => {
+ _resetNetworkMode();
+ });
+
+ it('FE-RESERV-011: a booking added offline shows at once under a temporary id and waits in the queue', async () => {
+ seedStore(useTripStore, { reservations: [] });
+
+ const saved = await useTripStore.getState().addReservation(1, { title: 'Night train', type: 'train' });
+
+ expect(saved.id).toBeLessThan(0);
+ expect(useTripStore.getState().reservations.map(r => r.title)).toEqual(['Night train']);
+ const [mutation] = await offlineDb.mutationQueue.toArray();
+ expect(mutation).toMatchObject({ method: 'POST', url: '/trips/1/reservations', tempId: saved.id });
+ });
+
+ it('FE-RESERV-012: the status toggle, travelers and transport order made offline stay and are queued', async () => {
+ const reservation = buildReservation({ id: 10, trip_id: 1, status: 'pending' });
+ seedStore(useTripStore, { reservations: [reservation] });
+ await offlineDb.reservations.put(reservation);
+
+ await useTripStore.getState().toggleReservationStatus(1, 10);
+ await useTripStore.getState().setReservationTravelers(1, 10, []);
+ await useTripStore.getState().updateReservationPositions(1, [{ id: 10, day_plan_position: 1 }], 3);
+
+ expect(useTripStore.getState().reservations[0]).toMatchObject({ status: 'confirmed', travelers: [] });
+ const urls = (await offlineDb.mutationQueue.toArray()).sort((a, b) => a.createdAt - b.createdAt).map(m => m.url);
+ expect(urls).toEqual(['/trips/1/reservations/10', '/trips/1/reservations/10/travelers', '/trips/1/reservations/positions']);
+ });
+
+ it('FE-RESERV-013: a booking deleted offline leaves the list and takes its linked costs with it', async () => {
+ seedStore(useTripStore, {
+ reservations: [buildReservation({ id: 10, trip_id: 1 })],
+ budgetItems: [buildBudgetItem({ id: 5, trip_id: 1, reservation_id: 10 })],
+ });
+
+ await useTripStore.getState().deleteReservation(1, 10);
+
+ expect(useTripStore.getState().reservations).toEqual([]);
+ expect(useTripStore.getState().budgetItems).toEqual([]);
+ expect((await offlineDb.mutationQueue.toArray())[0]).toMatchObject({ method: 'DELETE', url: '/trips/1/reservations/10' });
+ });
+ });
});
diff --git a/client/tests/unit/sync/mutationQueue.reservations.test.ts b/client/tests/unit/sync/mutationQueue.reservations.test.ts
new file mode 100644
index 000000000..97bba10ba
--- /dev/null
+++ b/client/tests/unit/sync/mutationQueue.reservations.test.ts
@@ -0,0 +1,106 @@
+/**
+ * mutationQueue replay of offline booking writes (#2601): a created booking
+ * moves to its real id, the travelers answer only ever touches the booking it
+ * names, and a hotel booked offline at a place created offline reaches the
+ * server linked to the place's real id, in the booking and in its stay.
+ */
+import 'fake-indexeddb/auto';
+import { http, HttpResponse } from 'msw';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { clearAll, offlineDb } from '../../../src/db/offlineDb';
+import { placeRepo } from '../../../src/repo/placeRepo';
+import { reservationRepo } from '../../../src/repo/reservationRepo';
+import { setAuthed } from '../../../src/sync/authGate';
+import { mutationQueue } from '../../../src/sync/mutationQueue';
+import { buildPlace, buildReservation } from '../../helpers/factories';
+import { server } from '../../helpers/msw/server';
+
+function setOnline(v: boolean): void {
+ Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true });
+}
+
+beforeEach(async () => {
+ await clearAll();
+ mutationQueue._resetFlushing();
+ setAuthed(true);
+ setOnline(false);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ setAuthed(false);
+ setOnline(true);
+});
+
+describe('mutationQueue.flush of bookings', () => {
+ it('FE-SYNC-RESV-001: a replayed booking moves to the real id and the queued edit follows it', async () => {
+ const { reservation } = await reservationRepo.create(3, { title: 'Flight' });
+ await reservationRepo.update(3, reservation.id, { title: 'Flight LH 400' });
+ const puts: string[] = [];
+ server.use(
+ http.post('/api/trips/3/reservations', () =>
+ HttpResponse.json({ reservation: buildReservation({ id: 901, trip_id: 3, title: 'Flight' }) })
+ ),
+ http.put('/api/trips/3/reservations/:id', ({ params }) => {
+ puts.push(String(params.id));
+ return HttpResponse.json({ reservation: buildReservation({ id: 901, trip_id: 3, title: 'Flight LH 400' }) });
+ })
+ );
+ setOnline(true);
+
+ await mutationQueue.flush();
+
+ expect(puts).toEqual(['901']);
+ expect(await offlineDb.mutationQueue.count()).toBe(0);
+ expect(await offlineDb.reservations.get(reservation.id)).toBeUndefined();
+ expect(await offlineDb.reservations.get(901)).toMatchObject({ title: 'Flight LH 400' });
+ });
+
+ it('FE-SYNC-RESV-002: the travelers answer lands on the booking it names', async () => {
+ await offlineDb.reservations.put(buildReservation({ id: 92, trip_id: 3, title: 'Tour', travelers: [] }));
+ await reservationRepo.setTravelers(3, 92, [4]);
+ const travelers = [{ user_id: 4, username: 'lena', avatar_url: null }];
+ server.use(
+ http.put('/api/trips/3/reservations/92/travelers', () =>
+ HttpResponse.json({ travelers, reservation: buildReservation({ id: 92, trip_id: 3, title: 'Tour' }) })
+ )
+ );
+ setOnline(true);
+
+ await mutationQueue.flush();
+
+ expect(await offlineDb.mutationQueue.count()).toBe(0);
+ const rows = await offlineDb.reservations.toArray();
+ expect(rows.map((r) => r.id)).toEqual([92]);
+ expect(rows[0].travelers).toEqual(travelers);
+ });
+
+ it('FE-SYNC-RESV-003: a hotel booked at a place created offline is sent with the real place id', async () => {
+ const { place } = await placeRepo.create(3, { name: 'Hotel Adler' });
+ await reservationRepo.create(3, {
+ title: 'Hotel Adler',
+ type: 'hotel',
+ place_id: place.id,
+ create_accommodation: { place_id: place.id, start_day_id: 1, end_day_id: 2 },
+ });
+ let sent: Record | null = null;
+ server.use(
+ http.post('/api/trips/3/places', () =>
+ HttpResponse.json({ place: buildPlace({ id: 77, trip_id: 3, name: 'Hotel Adler' }) })
+ ),
+ http.post('/api/trips/3/reservations', async ({ request }) => {
+ sent = (await request.json()) as Record;
+ return HttpResponse.json({ reservation: buildReservation({ id: 902, trip_id: 3, place_id: 77 }) });
+ })
+ );
+ setOnline(true);
+
+ await mutationQueue.flush();
+
+ expect(sent).toMatchObject({
+ place_id: 77,
+ create_accommodation: { place_id: 77, start_day_id: 1, end_day_id: 2 },
+ });
+ expect(await offlineDb.mutationQueue.count()).toBe(0);
+ });
+});
diff --git a/shared/src/i18n/ar/reservations.ts b/shared/src/i18n/ar/reservations.ts
index 5df51f0ba..68b092a42 100644
--- a/shared/src/i18n/ar/reservations.ts
+++ b/shared/src/i18n/ar/reservations.ts
@@ -286,5 +286,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'نسخ رمز الحجز',
'reservations.copyFailed': 'تعذّر النسخ',
'reservations.showInFiles': 'عرض في الملفات',
+ 'reservations.filesNeedConnection':
+ 'يحتاج رفع الملفات إلى اتصال. أزل الملفات الجديدة لحفظ الحجز دون اتصال، أو احفظ عندما تعود متصلاً.',
+ 'reservations.needsConnection': 'يتطلب هذا اتصالاً. حاول مرة أخرى عندما يعود TREK للاتصال.',
};
export default reservations;
diff --git a/shared/src/i18n/az/reservations.ts b/shared/src/i18n/az/reservations.ts
index 26778cc8e..c3f47deba 100644
--- a/shared/src/i18n/az/reservations.ts
+++ b/shared/src/i18n/az/reservations.ts
@@ -292,6 +292,9 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Rezervasiya kodunu kopyala',
'reservations.copyFailed': 'Kopyalamaq alınmadı',
'reservations.showInFiles': 'Fayllarda göstər',
+ 'reservations.filesNeedConnection':
+ 'Faylların yüklənməsi üçün bağlantı lazımdır. Rezervasiyanı oflayn saxlamaq üçün yeni faylları silin və ya yenidən onlayn olduqda saxlayın.',
+ 'reservations.needsConnection': 'Bunun üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.',
};
export default reservations;
diff --git a/shared/src/i18n/br/reservations.ts b/shared/src/i18n/br/reservations.ts
index 6a133bea3..db710bc81 100644
--- a/shared/src/i18n/br/reservations.ts
+++ b/shared/src/i18n/br/reservations.ts
@@ -258,5 +258,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Copiar código da reserva',
'reservations.copyFailed': 'Não foi possível copiar',
'reservations.showInFiles': 'Mostrar nos arquivos',
+ 'reservations.filesNeedConnection':
+ 'Os arquivos precisam de conexão para serem enviados. Remova os novos para salvar a reserva offline, ou salve quando estiver online de novo.',
+ 'reservations.needsConnection': 'Isso precisa de conexão. Tente de novo quando o TREK voltar a ficar online.',
};
export default reservations;
diff --git a/shared/src/i18n/ca/reservations.ts b/shared/src/i18n/ca/reservations.ts
index 7d557dd71..00cd3623a 100644
--- a/shared/src/i18n/ca/reservations.ts
+++ b/shared/src/i18n/ca/reservations.ts
@@ -261,5 +261,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Copiar el codi de reserva',
'reservations.copyFailed': "No s'ha pogut copiar",
'reservations.showInFiles': 'Mostrar als fitxers',
+ 'reservations.filesNeedConnection':
+ 'Els fitxers necessiten connexió per pujar-se. Treu els nous per desar la reserva sense connexió, o desa quan tornis a estar en línia.',
+ 'reservations.needsConnection': 'Això necessita connexió. Torna-ho a provar quan TREK torni a estar en línia.',
};
export default reservations;
diff --git a/shared/src/i18n/cs/reservations.ts b/shared/src/i18n/cs/reservations.ts
index 8f1662e70..4dcbfe916 100644
--- a/shared/src/i18n/cs/reservations.ts
+++ b/shared/src/i18n/cs/reservations.ts
@@ -265,5 +265,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Kopírovat rezervační kód',
'reservations.copyFailed': 'Kopírování se nezdařilo',
'reservations.showInFiles': 'Zobrazit v souborech',
+ 'reservations.filesNeedConnection':
+ 'Soubory potřebují k nahrání připojení. Odeber nové, abys rezervaci uložil offline, nebo ulož, až budeš znovu online.',
+ 'reservations.needsConnection': 'Na tohle je potřeba připojení. Zkus to znovu, až bude TREK zase online.',
};
export default reservations;
diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts
index 51ba428a1..7aacb409d 100644
--- a/shared/src/i18n/de/reservations.ts
+++ b/shared/src/i18n/de/reservations.ts
@@ -261,5 +261,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Buchungscode kopieren',
'reservations.copyFailed': 'Kopieren hat nicht geklappt',
'reservations.showInFiles': 'In Dateien zeigen',
+ 'reservations.filesNeedConnection':
+ 'Dateien brauchen zum Hochladen eine Verbindung. Entferne die neuen, um die Buchung offline zu speichern, oder speichere, sobald du wieder online bist.',
+ 'reservations.needsConnection': 'Dafür braucht es eine Verbindung. Versuch es noch einmal, sobald TREK wieder online ist.',
};
export default reservations;
diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts
index e600be319..b38779437 100644
--- a/shared/src/i18n/en/reservations.ts
+++ b/shared/src/i18n/en/reservations.ts
@@ -260,5 +260,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Copy booking code',
'reservations.copyFailed': 'Could not copy',
'reservations.showInFiles': 'Show in files',
+ 'reservations.filesNeedConnection':
+ 'Files need a connection to upload. Remove the new ones to save the booking offline, or save once you are back online.',
+ 'reservations.needsConnection': 'This needs a connection. Try again once TREK is back online.',
};
export default reservations;
diff --git a/shared/src/i18n/es/reservations.ts b/shared/src/i18n/es/reservations.ts
index d37586211..187070411 100644
--- a/shared/src/i18n/es/reservations.ts
+++ b/shared/src/i18n/es/reservations.ts
@@ -261,5 +261,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Copiar código de reserva',
'reservations.copyFailed': 'No se pudo copiar',
'reservations.showInFiles': 'Mostrar en archivos',
+ 'reservations.filesNeedConnection':
+ 'Los archivos necesitan conexión para subirse. Quita los nuevos para guardar la reserva sin conexión, o guarda cuando vuelvas a estar en línea.',
+ 'reservations.needsConnection': 'Esto necesita conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.',
};
export default reservations;
diff --git a/shared/src/i18n/et/reservations.ts b/shared/src/i18n/et/reservations.ts
index dec52dd7c..25334d04d 100644
--- a/shared/src/i18n/et/reservations.ts
+++ b/shared/src/i18n/et/reservations.ts
@@ -260,5 +260,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Kopeeri broneerimiskood',
'reservations.copyFailed': 'Kopeerimine ebaõnnestus',
'reservations.showInFiles': 'Näita failides',
+ 'reservations.filesNeedConnection':
+ 'Failide üleslaadimiseks on vaja ühendust. Eemalda uued, et broneering võrguühenduseta salvestada, või salvesta, kui oled taas võrgus.',
+ 'reservations.needsConnection': 'Selleks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.',
};
export default reservations;
diff --git a/shared/src/i18n/fr/reservations.ts b/shared/src/i18n/fr/reservations.ts
index 72222443b..158adb631 100644
--- a/shared/src/i18n/fr/reservations.ts
+++ b/shared/src/i18n/fr/reservations.ts
@@ -264,5 +264,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Copier le code de réservation',
'reservations.copyFailed': 'Impossible de copier',
'reservations.showInFiles': 'Afficher dans les fichiers',
+ 'reservations.filesNeedConnection':
+ "Les fichiers ont besoin d'une connexion pour être envoyés. Retire les nouveaux pour enregistrer la réservation hors ligne, ou enregistre une fois de retour en ligne.",
+ 'reservations.needsConnection': 'Cela nécessite une connexion. Réessaie dès que TREK sera de nouveau en ligne.',
};
export default reservations;
diff --git a/shared/src/i18n/gr/reservations.ts b/shared/src/i18n/gr/reservations.ts
index 33177f34f..5a8133fa5 100644
--- a/shared/src/i18n/gr/reservations.ts
+++ b/shared/src/i18n/gr/reservations.ts
@@ -262,5 +262,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Αντιγραφή κωδικού κράτησης',
'reservations.copyFailed': 'Η αντιγραφή απέτυχε',
'reservations.showInFiles': 'Εμφάνιση στα αρχεία',
+ 'reservations.filesNeedConnection':
+ 'Τα αρχεία χρειάζονται σύνδεση για να ανέβουν. Αφαίρεσε τα νέα για να αποθηκεύσεις την κράτηση εκτός σύνδεσης ή αποθήκευσε όταν είσαι ξανά online.',
+ 'reservations.needsConnection': 'Αυτό χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.',
};
export default reservations;
diff --git a/shared/src/i18n/hu/reservations.ts b/shared/src/i18n/hu/reservations.ts
index 5edc50e33..7c1eef6cf 100644
--- a/shared/src/i18n/hu/reservations.ts
+++ b/shared/src/i18n/hu/reservations.ts
@@ -262,5 +262,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Foglalási kód másolása',
'reservations.copyFailed': 'Nem sikerült másolni',
'reservations.showInFiles': 'Megjelenítés a fájlokban',
+ 'reservations.filesNeedConnection':
+ 'A fájlok feltöltéséhez kapcsolat kell. Távolítsd el az újakat, hogy offline mentsd a foglalást, vagy ments, amikor újra online vagy.',
+ 'reservations.needsConnection': 'Ehhez kapcsolat kell. Próbáld újra, amint a TREK újra online lesz.',
};
export default reservations;
diff --git a/shared/src/i18n/id/reservations.ts b/shared/src/i18n/id/reservations.ts
index a4e92983c..7a426e6e7 100644
--- a/shared/src/i18n/id/reservations.ts
+++ b/shared/src/i18n/id/reservations.ts
@@ -251,5 +251,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Salin kode pemesanan',
'reservations.copyFailed': 'Tidak dapat menyalin',
'reservations.showInFiles': 'Tampilkan di File',
+ 'reservations.filesNeedConnection':
+ 'File perlu koneksi untuk diunggah. Hapus file baru untuk menyimpan pemesanan secara offline, atau simpan saat kamu kembali online.',
+ 'reservations.needsConnection': 'Ini memerlukan koneksi. Coba lagi begitu TREK kembali online.',
};
export default reservations;
diff --git a/shared/src/i18n/it/reservations.ts b/shared/src/i18n/it/reservations.ts
index 23b654526..9aa79a958 100644
--- a/shared/src/i18n/it/reservations.ts
+++ b/shared/src/i18n/it/reservations.ts
@@ -259,5 +259,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Copia codice prenotazione',
'reservations.copyFailed': 'Impossibile copiare',
'reservations.showInFiles': 'Mostra nei file',
+ 'reservations.filesNeedConnection':
+ 'I file hanno bisogno di una connessione per essere caricati. Rimuovi quelli nuovi per salvare la prenotazione offline, oppure salva quando sei di nuovo online.',
+ 'reservations.needsConnection': 'Serve una connessione. Riprova quando TREK tornerà online.',
};
export default reservations;
diff --git a/shared/src/i18n/ja/reservations.ts b/shared/src/i18n/ja/reservations.ts
index d98f07e0b..940278836 100644
--- a/shared/src/i18n/ja/reservations.ts
+++ b/shared/src/i18n/ja/reservations.ts
@@ -251,5 +251,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': '予約コードをコピー',
'reservations.copyFailed': 'コピーできませんでした',
'reservations.showInFiles': 'ファイルで表示',
+ 'reservations.filesNeedConnection':
+ 'ファイルのアップロードには接続が必要です。オフラインで予約を保存するには新しいファイルを外すか、オンラインに戻ってから保存してください。',
+ 'reservations.needsConnection': 'この操作には接続が必要です。TREK がオンラインに戻ってから、もう一度お試しください。',
};
export default reservations;
diff --git a/shared/src/i18n/ko/reservations.ts b/shared/src/i18n/ko/reservations.ts
index f83d4ef9b..28e67ad9c 100644
--- a/shared/src/i18n/ko/reservations.ts
+++ b/shared/src/i18n/ko/reservations.ts
@@ -251,5 +251,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': '예약 코드 복사',
'reservations.copyFailed': '복사하지 못했습니다',
'reservations.showInFiles': '파일에서 보기',
+ 'reservations.filesNeedConnection':
+ '파일을 업로드하려면 연결이 필요합니다. 오프라인으로 예약을 저장하려면 새 파일을 제거하거나, 다시 온라인이 되면 저장하세요.',
+ 'reservations.needsConnection': '연결이 필요한 작업입니다. TREK가 다시 온라인이 되면 다시 시도하세요.',
};
export default reservations;
diff --git a/shared/src/i18n/nl/reservations.ts b/shared/src/i18n/nl/reservations.ts
index dc408d220..1081ae784 100644
--- a/shared/src/i18n/nl/reservations.ts
+++ b/shared/src/i18n/nl/reservations.ts
@@ -260,5 +260,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Boekingscode kopiëren',
'reservations.copyFailed': 'Kopiëren is mislukt',
'reservations.showInFiles': 'Tonen in bestanden',
+ 'reservations.filesNeedConnection':
+ 'Bestanden hebben een verbinding nodig om te uploaden. Verwijder de nieuwe om de boeking offline op te slaan, of sla op zodra je weer online bent.',
+ 'reservations.needsConnection': 'Hiervoor is een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.',
};
export default reservations;
diff --git a/shared/src/i18n/pl/reservations.ts b/shared/src/i18n/pl/reservations.ts
index 615092f29..d2b7a6e36 100644
--- a/shared/src/i18n/pl/reservations.ts
+++ b/shared/src/i18n/pl/reservations.ts
@@ -273,5 +273,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Kopiuj kod rezerwacji',
'reservations.copyFailed': 'Nie udało się skopiować',
'reservations.showInFiles': 'Pokaż w plikach',
+ 'reservations.filesNeedConnection':
+ 'Pliki wymagają połączenia do przesłania. Usuń nowe, aby zapisać rezerwację offline, albo zapisz po powrocie online.',
+ 'reservations.needsConnection': 'To wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.',
};
export default reservations;
diff --git a/shared/src/i18n/ru/reservations.ts b/shared/src/i18n/ru/reservations.ts
index 74425a744..8b4b613ff 100644
--- a/shared/src/i18n/ru/reservations.ts
+++ b/shared/src/i18n/ru/reservations.ts
@@ -272,5 +272,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Копировать код бронирования',
'reservations.copyFailed': 'Не удалось скопировать',
'reservations.showInFiles': 'Показать в файлах',
+ 'reservations.filesNeedConnection':
+ 'Для загрузки файлов нужно подключение. Удалите новые, чтобы сохранить бронирование офлайн, или сохраните, когда снова будете онлайн.',
+ 'reservations.needsConnection': 'Для этого нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.',
};
export default reservations;
diff --git a/shared/src/i18n/sk/reservations.ts b/shared/src/i18n/sk/reservations.ts
index 7a9e97f48..3d3477dc5 100644
--- a/shared/src/i18n/sk/reservations.ts
+++ b/shared/src/i18n/sk/reservations.ts
@@ -268,5 +268,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Kopírovať rezervačný kód',
'reservations.copyFailed': 'Kopírovanie zlyhalo',
'reservations.showInFiles': 'Zobraziť v súboroch',
+ 'reservations.filesNeedConnection':
+ 'Súbory potrebujú na nahratie pripojenie. Odstráň nové, aby si rezerváciu uložil offline, alebo ulož, keď budeš znova online.',
+ 'reservations.needsConnection': 'Na toto je potrebné pripojenie. Skús to znova, keď bude TREK opäť online.',
};
export default reservations;
diff --git a/shared/src/i18n/sv/reservations.ts b/shared/src/i18n/sv/reservations.ts
index e346509c9..a489065aa 100644
--- a/shared/src/i18n/sv/reservations.ts
+++ b/shared/src/i18n/sv/reservations.ts
@@ -259,5 +259,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Kopiera bokningskod',
'reservations.copyFailed': 'Kunde inte kopiera',
'reservations.showInFiles': 'Visa i filer',
+ 'reservations.filesNeedConnection':
+ 'Filer behöver en anslutning för att laddas upp. Ta bort de nya för att spara bokningen offline, eller spara när du är online igen.',
+ 'reservations.needsConnection': 'Det här kräver en anslutning. Försök igen när TREK är online igen.',
};
export default reservations;
diff --git a/shared/src/i18n/th/reservations.ts b/shared/src/i18n/th/reservations.ts
index 875b2271b..b0a3ed54b 100644
--- a/shared/src/i18n/th/reservations.ts
+++ b/shared/src/i18n/th/reservations.ts
@@ -253,5 +253,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'คัดลอกรหัสการจอง',
'reservations.copyFailed': 'คัดลอกไม่สำเร็จ',
'reservations.showInFiles': 'แสดงในไฟล์',
+ 'reservations.filesNeedConnection':
+ 'การอัปโหลดไฟล์ต้องใช้การเชื่อมต่อ ลบไฟล์ใหม่ออกเพื่อบันทึกการจองแบบออฟไลน์ หรือบันทึกเมื่อกลับมาออนไลน์',
+ 'reservations.needsConnection': 'การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์',
};
export default reservations;
diff --git a/shared/src/i18n/tr/reservations.ts b/shared/src/i18n/tr/reservations.ts
index 308ceeb63..3c9017928 100644
--- a/shared/src/i18n/tr/reservations.ts
+++ b/shared/src/i18n/tr/reservations.ts
@@ -260,5 +260,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Rezervasyon kodunu kopyala',
'reservations.copyFailed': 'Kopyalanamadı',
'reservations.showInFiles': 'Dosyalarda göster',
+ 'reservations.filesNeedConnection':
+ 'Dosyaların yüklenmesi için bağlantı gerekir. Rezervasyonu çevrimdışı kaydetmek için yenilerini kaldır ya da tekrar çevrimiçi olduğunda kaydet.',
+ 'reservations.needsConnection': 'Bunun için bağlantı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.',
};
export default reservations;
diff --git a/shared/src/i18n/uk/reservations.ts b/shared/src/i18n/uk/reservations.ts
index 2d4435467..cca068cef 100644
--- a/shared/src/i18n/uk/reservations.ts
+++ b/shared/src/i18n/uk/reservations.ts
@@ -272,5 +272,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Копіювати код бронювання',
'reservations.copyFailed': 'Не вдалося скопіювати',
'reservations.showInFiles': 'Показати у файлах',
+ 'reservations.filesNeedConnection':
+ "Для завантаження файлів потрібне з'єднання. Видаліть нові, щоб зберегти бронювання офлайн, або збережіть, коли знову будете онлайн.",
+ 'reservations.needsConnection': "Для цього потрібне з'єднання. Спробуйте ще раз, коли TREK знову буде онлайн.",
};
export default reservations;
diff --git a/shared/src/i18n/vi/reservations.ts b/shared/src/i18n/vi/reservations.ts
index 829fbf017..bd67673dc 100644
--- a/shared/src/i18n/vi/reservations.ts
+++ b/shared/src/i18n/vi/reservations.ts
@@ -254,5 +254,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': 'Sao chép mã đặt chỗ',
'reservations.copyFailed': 'Không thể sao chép',
'reservations.showInFiles': 'Hiện trong Tập tin',
+ 'reservations.filesNeedConnection':
+ 'Cần có kết nối để tải tệp lên. Gỡ các tệp mới để lưu đặt chỗ khi ngoại tuyến, hoặc lưu khi bạn trực tuyến trở lại.',
+ 'reservations.needsConnection': 'Thao tác này cần kết nối. Hãy thử lại khi TREK trực tuyến trở lại.',
};
export default reservations;
diff --git a/shared/src/i18n/zh-TW/reservations.ts b/shared/src/i18n/zh-TW/reservations.ts
index 29ad85780..4b137d8db 100644
--- a/shared/src/i18n/zh-TW/reservations.ts
+++ b/shared/src/i18n/zh-TW/reservations.ts
@@ -250,5 +250,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': '複製預訂碼',
'reservations.copyFailed': '無法複製',
'reservations.showInFiles': '在檔案中顯示',
+ 'reservations.filesNeedConnection':
+ '上傳檔案需要網路連線。移除新檔案即可離線儲存這筆預訂,或在恢復連線後再儲存。',
+ 'reservations.needsConnection': '此操作需要網路連線。請在 TREK 恢復連線後再試一次。',
};
export default reservations;
diff --git a/shared/src/i18n/zh/reservations.ts b/shared/src/i18n/zh/reservations.ts
index 93e8aaf32..343c86293 100644
--- a/shared/src/i18n/zh/reservations.ts
+++ b/shared/src/i18n/zh/reservations.ts
@@ -250,5 +250,8 @@ const reservations: TranslationStrings = {
'reservations.copyCode': '复制预订码',
'reservations.copyFailed': '无法复制',
'reservations.showInFiles': '在文件中显示',
+ 'reservations.filesNeedConnection':
+ '上传文件需要网络连接。移除新文件即可离线保存这条预订,或在恢复联网后再保存。',
+ 'reservations.needsConnection': '此操作需要网络连接。请在 TREK 恢复联网后重试。',
};
export default reservations;