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