mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(bookings): bookings, transports and stays saved offline wait in the queue and sync once the connection is back
Every booking and stay write went straight to the API, so offline a new
booking failed with "Network Error" and was lost, and a status tick, the
travellers or a moved transport snapped back. Bookings (add, edit, status,
travellers, delete, day order) and stays (add, edit, delete) now go through
reservationRepo and accommodationRepo and the mutation queue like places and
costs: a temporary row offline, held behind an older queued write online,
since neither carries a version token. Desktop, the phone sheets, the day
plan, the road trip nights and the PDF all write and read through them.
The replay reads the booking from `reservation` only, so the travellers
answer ({ travelers, reservation }) lands on the booking it names. A booking
or its stay that names a place or stay created offline is sent with the real
id once that one has synced, inside create_accommodation too, so an imported
hotel reviewed offline can be linked to a place made for it.
What only works online says so instead of failing: an AirTrail import, a
booking file import, the AI retry and an attachment on a saved booking show
that they need a connection, and a save with newly picked files is stopped
with a hint, since an upload cannot wait in the queue.
Refs #2601
This commit is contained in:
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(<AirTrailImportModal {...defaultProps} />);
|
||||
expect(await screen.findByText('This needs a connection. Try again once TREK is back online.')).toBeInTheDocument();
|
||||
} finally {
|
||||
_resetNetworkMode();
|
||||
}
|
||||
expect(flights).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<EventTarget | null>(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 }))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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) {
|
||||
|
||||
@@ -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<typeof accommodationsApi.create>[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<typeof accommodationRepo.create>[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))
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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<string, unknown>).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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<QueuedMutation[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Accommodation> {
|
||||
const row: Record<string, unknown> = { ...(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<void> {
|
||||
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<StayWriteResult> {
|
||||
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<StayWriteResult> {
|
||||
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<StayStopsResult> {
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<QueuedMutation[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Reservation> {
|
||||
const fields: Record<string, unknown> = { ...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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 },
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
deleteReservation: (tripId: number | string, id: number) => Promise<void>
|
||||
setReservationTravelers: (tripId: number | string, id: number, userIds: number[]) => Promise<void>
|
||||
updateReservationPositions: (tripId: number | string, positions: ReservationPosition[], dayId?: number) => Promise<void>
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<number, number>): 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<number, number>): Promise<void> {
|
||||
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') {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function buildTripActions(): Record<string, ReturnType<typeof vi.fn>> {
|
||||
'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<string, ReturnType<typeof vi.fn>> = {};
|
||||
for (const name of names) actions[name] = vi.fn(async () => undefined);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -286,5 +286,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': 'نسخ رمز الحجز',
|
||||
'reservations.copyFailed': 'تعذّر النسخ',
|
||||
'reservations.showInFiles': 'عرض في الملفات',
|
||||
'reservations.filesNeedConnection':
|
||||
'يحتاج رفع الملفات إلى اتصال. أزل الملفات الجديدة لحفظ الحجز دون اتصال، أو احفظ عندما تعود متصلاً.',
|
||||
'reservations.needsConnection': 'يتطلب هذا اتصالاً. حاول مرة أخرى عندما يعود TREK للاتصال.',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -262,5 +262,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': 'Αντιγραφή κωδικού κράτησης',
|
||||
'reservations.copyFailed': 'Η αντιγραφή απέτυχε',
|
||||
'reservations.showInFiles': 'Εμφάνιση στα αρχεία',
|
||||
'reservations.filesNeedConnection':
|
||||
'Τα αρχεία χρειάζονται σύνδεση για να ανέβουν. Αφαίρεσε τα νέα για να αποθηκεύσεις την κράτηση εκτός σύνδεσης ή αποθήκευσε όταν είσαι ξανά online.',
|
||||
'reservations.needsConnection': 'Αυτό χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -251,5 +251,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': '予約コードをコピー',
|
||||
'reservations.copyFailed': 'コピーできませんでした',
|
||||
'reservations.showInFiles': 'ファイルで表示',
|
||||
'reservations.filesNeedConnection':
|
||||
'ファイルのアップロードには接続が必要です。オフラインで予約を保存するには新しいファイルを外すか、オンラインに戻ってから保存してください。',
|
||||
'reservations.needsConnection': 'この操作には接続が必要です。TREK がオンラインに戻ってから、もう一度お試しください。',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -251,5 +251,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': '예약 코드 복사',
|
||||
'reservations.copyFailed': '복사하지 못했습니다',
|
||||
'reservations.showInFiles': '파일에서 보기',
|
||||
'reservations.filesNeedConnection':
|
||||
'파일을 업로드하려면 연결이 필요합니다. 오프라인으로 예약을 저장하려면 새 파일을 제거하거나, 다시 온라인이 되면 저장하세요.',
|
||||
'reservations.needsConnection': '연결이 필요한 작업입니다. TREK가 다시 온라인이 되면 다시 시도하세요.',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -272,5 +272,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': 'Копировать код бронирования',
|
||||
'reservations.copyFailed': 'Не удалось скопировать',
|
||||
'reservations.showInFiles': 'Показать в файлах',
|
||||
'reservations.filesNeedConnection':
|
||||
'Для загрузки файлов нужно подключение. Удалите новые, чтобы сохранить бронирование офлайн, или сохраните, когда снова будете онлайн.',
|
||||
'reservations.needsConnection': 'Для этого нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -253,5 +253,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': 'คัดลอกรหัสการจอง',
|
||||
'reservations.copyFailed': 'คัดลอกไม่สำเร็จ',
|
||||
'reservations.showInFiles': 'แสดงในไฟล์',
|
||||
'reservations.filesNeedConnection':
|
||||
'การอัปโหลดไฟล์ต้องใช้การเชื่อมต่อ ลบไฟล์ใหม่ออกเพื่อบันทึกการจองแบบออฟไลน์ หรือบันทึกเมื่อกลับมาออนไลน์',
|
||||
'reservations.needsConnection': 'การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -272,5 +272,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': 'Копіювати код бронювання',
|
||||
'reservations.copyFailed': 'Не вдалося скопіювати',
|
||||
'reservations.showInFiles': 'Показати у файлах',
|
||||
'reservations.filesNeedConnection':
|
||||
"Для завантаження файлів потрібне з'єднання. Видаліть нові, щоб зберегти бронювання офлайн, або збережіть, коли знову будете онлайн.",
|
||||
'reservations.needsConnection': "Для цього потрібне з'єднання. Спробуйте ще раз, коли TREK знову буде онлайн.",
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -250,5 +250,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': '複製預訂碼',
|
||||
'reservations.copyFailed': '無法複製',
|
||||
'reservations.showInFiles': '在檔案中顯示',
|
||||
'reservations.filesNeedConnection':
|
||||
'上傳檔案需要網路連線。移除新檔案即可離線儲存這筆預訂,或在恢復連線後再儲存。',
|
||||
'reservations.needsConnection': '此操作需要網路連線。請在 TREK 恢復連線後再試一次。',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -250,5 +250,8 @@ const reservations: TranslationStrings = {
|
||||
'reservations.copyCode': '复制预订码',
|
||||
'reservations.copyFailed': '无法复制',
|
||||
'reservations.showInFiles': '在文件中显示',
|
||||
'reservations.filesNeedConnection':
|
||||
'上传文件需要网络连接。移除新文件即可离线保存这条预订,或在恢复联网后再保存。',
|
||||
'reservations.needsConnection': '此操作需要网络连接。请在 TREK 恢复联网后重试。',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
Reference in New Issue
Block a user