diff --git a/client/scripts/offline-baseline.json b/client/scripts/offline-baseline.json index a6b13ba15..537743af4 100644 --- a/client/scripts/offline-baseline.json +++ b/client/scripts/offline-baseline.json @@ -52,7 +52,6 @@ "src/components/Journey/ContributorInviteDialog.tsx", "src/components/Journey/JourneyDetailPageEntryCard.tsx", "src/components/Journey/JourneyDetailPageEntryEditor.tsx", - "src/components/Journey/JourneyDetailPageGalleryView.tsx", "src/components/Journey/JourneyDetailPageProviderPicker.tsx", "src/components/Journey/useConnectedPhotoProviders.ts", "src/components/Journey/useEntryPhotoOrder.ts", @@ -80,16 +79,13 @@ "src/components/Planner/AirTrailImportModal.tsx", "src/components/Planner/AirportSelect.tsx", "src/components/Planner/BookingImportModal.tsx", - "src/components/Planner/DayPlanSidebar.tsx", "src/components/Planner/LocationSelect.tsx", "src/components/Planner/PlaceDetailsColumn.tsx", "src/components/Planner/PlaceInspector.tsx", "src/components/Planner/TransitSearchPanel.tsx", "src/components/Planner/TripWarningsBanner.tsx", "src/components/Planner/useDayDetail.ts", - "src/components/Planner/useLegModeActions.ts", "src/components/Planner/useListImport.ts", - "src/components/Planner/usePlaceActions.ts", "src/components/Planner/usePlaceSearch.ts", "src/components/Planner/usePlacesFileImport.ts", "src/components/Planner/usePlacesPool.ts", diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 27178b1d0..54e6bc0b0 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -4223,12 +4223,10 @@ describe('DayPlanSidebar', () => { expect(useTripStore.getState().days[0].default_transport_mode).toBe('walking') }) - it('FE-PLANNER-DAYPLAN-170: a failing day-mode save is reported and the days are refetched', async () => { + it('FE-PLANNER-DAYPLAN-170: a failing day-mode save is reported and the day gets its old mode back', async () => { const user = userEvent.setup() const { daysApi } = await import('../../api/client') vi.mocked(daysApi.updateTransport).mockRejectedValueOnce(new Error('offline')) - const refreshDays = vi.fn(async () => undefined) - stubTripActions({ refreshDays }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) const assignments = { '10': [ @@ -4236,10 +4234,11 @@ describe('DayPlanSidebar', () => { buildAssignment({ id: 12, day_id: 10, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }), ], } + seedStore(useTripStore, { days: [day] }) render() await user.click(screen.getByRole('button', { name: 'Walking' })) await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('offline')) - expect(refreshDays).toHaveBeenCalledWith(1) + expect(useTripStore.getState().days[0].default_transport_mode).toBeFalsy() }) it('FE-PLANNER-DAYPLAN-171: a route connector opens the per-segment travel-mode menu', async () => { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 63ac75d59..cd2c502cd 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -10,7 +10,6 @@ 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 { 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' @@ -1392,11 +1391,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP // and mirrored into routeProfile so the live map redraws at once. Legs that carry // their own mode are left untouched. const setDayDefaultMode = (dayId: number, mode: string) => { - useTripStore.setState(state => ({ days: state.days.map(d => (d.id === dayId ? { ...d, default_transport_mode: mode } : d)) })) onSetRouteProfile?.(mode) - daysApi.updateTransport(tripId, dayId, mode).catch((err: unknown) => { + tripActions.setDayTransportMode(tripId, dayId, mode).catch((err: unknown) => { toast.error(err instanceof Error ? err.message : t('common.unknownError')) - void tripActions.refreshDays(tripId) }) } diff --git a/client/src/components/Planner/useLegModeActions.test.ts b/client/src/components/Planner/useLegModeActions.test.ts index be18c6724..1cc30044d 100644 --- a/client/src/components/Planner/useLegModeActions.test.ts +++ b/client/src/components/Planner/useLegModeActions.test.ts @@ -56,10 +56,13 @@ describe('useLegModeActions', () => { result.current.persistLegMode(11, 'walking'); result.current.persistLegMode(12, null, 'incoming'); }); - expect(vi.mocked(assignmentsApi.updateTransport).mock.calls).toEqual([ - [3, 11, 'walking'], - [3, 12, null, 'incoming'], - ]); + // The repo first asks the queue whether an older write to the leg waits. + await waitFor(() => + expect(vi.mocked(assignmentsApi.updateTransport).mock.calls).toEqual([ + [3, 11, 'walking'], + [3, 12, null, 'incoming'], + ]) + ); expect(d.toast.error).not.toHaveBeenCalled(); expect(d.tripActions.refreshDays).not.toHaveBeenCalled(); }); diff --git a/client/src/components/Planner/useLegModeActions.ts b/client/src/components/Planner/useLegModeActions.ts index 22a8f45aa..ef257f437 100644 --- a/client/src/components/Planner/useLegModeActions.ts +++ b/client/src/components/Planner/useLegModeActions.ts @@ -1,7 +1,7 @@ import { type LucideIcon, RotateCcw, TramFront } from 'lucide-react'; import { useCallback } from 'react'; -import { assignmentsApi } from '../../api/client'; +import { assignmentRepo } from '../../repo/assignmentRepo'; import type { TripStoreState } from '../../store/tripStore'; import { type RouteModeOption, routeModeIcon, useRouteModeOptions } from './routeModes'; @@ -52,10 +52,7 @@ export function useLegModeActions({ tripId, toast, t, tripActions }: LegModeDeps const persistLegMode = useCallback( (assignmentId: number, mode: string | null, direction?: 'incoming') => { - const write = direction - ? assignmentsApi.updateTransport(tripId, assignmentId, mode, direction) - : assignmentsApi.updateTransport(tripId, assignmentId, mode); - write.catch((err: unknown) => { + assignmentRepo.setLegMode(tripId, assignmentId, mode, direction).catch((err: unknown) => { toast.error(err instanceof Error ? err.message : t('common.unknownError')); void tripActions.refreshDays(tripId); }); diff --git a/client/src/components/Planner/usePlaceActions.ts b/client/src/components/Planner/usePlaceActions.ts index 62ff6f9ca..ad115c4c7 100644 --- a/client/src/components/Planner/usePlaceActions.ts +++ b/client/src/components/Planner/usePlaceActions.ts @@ -1,6 +1,5 @@ import { type ChangeEvent, useCallback, useRef, useState } from 'react'; -import { assignmentsApi } from '../../api/client'; import { translateApiError, useTranslation } from '../../i18n'; import type { TripStoreState } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore'; @@ -66,15 +65,7 @@ export function placeActions({ tripId, tripActions, toast, t }: PlaceActionDeps) /** Who joins the assignment; the answer is written back into that day's list. */ const setParticipants = async (assignmentId: number, dayId: number, userIds: number[]) => { try { - const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds); - useTripStore.setState((state) => ({ - assignments: { - ...state.assignments, - [String(dayId)]: (state.assignments[String(dayId)] || []).map((a) => - a.id === assignmentId ? { ...a, participants: data.participants } : a - ), - }, - })); + await useTripStore.getState().setAssignmentParticipants(tripId, dayId, assignmentId, userIds); } catch (err: unknown) { report(err); } diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index 9e671b26f..45fae3a66 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -1520,4 +1520,31 @@ describe('TripFormModal', () => { await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2)); expect(onSave.mock.calls[1][0]).toMatchObject({ day_count: 6 }); }); + + // #2601: offline a new name still saves, while what only the server can do says so. + describe('offline', () => { + const setOnline = (v: boolean) => Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); + afterEach(() => setOnline(true)); + + it('FE-COMP-TRIPFORM-098: a new name is saved offline, new dates and a new trip are refused with the hint', async () => { + const user = userEvent.setup(); + const trip = buildTrip({ id: 1, title: 'Rome', start_date: '2026-11-02', end_date: '2026-11-04', currency: 'EUR' }); + const onSave = vi.fn().mockResolvedValue(undefined); + setOnline(false); + const { unmount } = render(); + const title = screen.getByDisplayValue('Rome'); + await user.clear(title); + await user.type(title, 'Roma'); + await user.click(screen.getByRole('button', { name: /Update/i })); + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'Roma', start_date: '2026-11-02' }))); + unmount(); + + const created = vi.fn(); + render(); + await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Paris 2026'); + await submitNewTrip(user); + expect(await screen.findByText(/This needs a connection/)).toBeInTheDocument(); + expect(created).not.toHaveBeenCalled(); + }); + }); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 7e5bd31ce..a5bb59f7f 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -18,6 +18,8 @@ import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuar import type { ShiftMode } from '../../utils/dayImpactLines' import { useTripCoverPicker } from './useTripCoverPicker' import { tripFormError } from './tripFormModel' +import { isEffectivelyOffline } from '../../sync/networkMode' +import { tripChangeNeedsConnection } from '../../repo/tripRepo' type DateShiftMode = ShiftMode type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode } @@ -158,6 +160,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp // Moving the start of a dated trip shifts the whole day grid, so let the user // choose how bookings follow before anything is saved (#1288). End-date-only // changes don't shift days and dateless transitions have nothing to shift. + if (refusedOffline(payload)) return const askShift = !!(isEditing && trip?.start_date && trip?.end_date && payload.start_date && payload.end_date && payload.start_date !== trip.start_date) // And say which days the new range removes, with what is on them, before @@ -171,7 +174,16 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp await performSave(payload) } + // A new trip, new dates or a new currency are made by the server; a new name + // or description waits in the queue offline like any other change. + const refusedOffline = (payload: TripPayload): boolean => { + if (!isEffectivelyOffline() || (isEditing && !tripChangeNeedsConnection(trip ?? undefined, payload))) return false + setError(t(isEditing ? 'dashboard.tripChangeNeedsConnection' : 'common.needsConnection')) + return true + } + const performSave = async (payload: TripPayload) => { + if (refusedOffline(payload)) return setIsLoading(true) try { const result = await onSave(payload) @@ -526,6 +538,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-body font-medium bg-surface text-content border border-edge" onClick={async () => { if (m.id === currentUser?.id) return + if (isEffectivelyOffline()) { toast.error(t('common.needsConnection')); return } try { await tripsApi.removeMember(trip!.id, m.id) setExistingMembers(prev => prev.filter(x => x.id !== m.id)) @@ -560,7 +573,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp onChange={async value => { if (isEditing && trip?.id) { const user = allUsers.find(u => u.id === Number(value)) - if (user) { + if (user && isEffectivelyOffline()) { + toast.error(t('common.needsConnection')) + } else if (user) { try { await tripsApi.addMember(trip.id, user.username) setExistingMembers(prev => [...prev, { id: user.id, username: user.username }]) diff --git a/client/src/components/Trips/useTripCoverPicker.ts b/client/src/components/Trips/useTripCoverPicker.ts index 3e860137e..a3e98ba2b 100644 --- a/client/src/components/Trips/useTripCoverPicker.ts +++ b/client/src/components/Trips/useTripCoverPicker.ts @@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from 'react'; import { tripsApi } from '../../api/client'; import { useTranslation } from '../../i18n'; +import { isEffectivelyOffline } from '../../sync/networkMode'; import { getApiErrorMessage } from '../../types'; import { normalizeImageFile } from '../../utils/convertHeic'; +import { refusedOffline } from '../../utils/needsConnection'; import { useToast } from '../shared/Toast'; interface CoverSearchPhoto { @@ -93,6 +95,7 @@ export function useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewO }; const uploadCoverNow = async (tripId: number, file: File) => { + if (refusedOffline(toast, t)) return; setUploadingCover(true); try { const fd = new FormData(); @@ -131,6 +134,10 @@ export function useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewO } // Guard against out-of-order responses: only the latest search applies its // results, so a slow earlier query can't overwrite a newer one. #1277 review + if (isEffectivelyOffline()) { + setSearchError(t('common.needsConnection')); + return; + } const seq = ++coverSearchSeq.current; setSearching(true); setSearchError(''); @@ -152,6 +159,7 @@ export function useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewO if (!photo.url) return; setPendingCoverFile(null); if (isEditing && trip?.id) { + if (refusedOffline(toast, t)) return; setUploadingCover(true); try { await tripsApi.update(trip.id, { cover_image: photo.url }); @@ -178,6 +186,7 @@ export function useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewO } // Nothing pending left, so the preview is a saved trip's stored cover. const id = trip!.id; + if (refusedOffline(toast, t)) return; try { await tripsApi.update(id, { cover_image: null }); setCoverPreview(null); diff --git a/client/src/components/Trips/useTripShare.ts b/client/src/components/Trips/useTripShare.ts index 66304bc2e..5075dc4fe 100644 --- a/client/src/components/Trips/useTripShare.ts +++ b/client/src/components/Trips/useTripShare.ts @@ -8,6 +8,7 @@ import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' import { getApiErrorMessage } from '../../types' import { copyText } from '../../utils/clipboard' +import { refusedOffline } from '../../utils/needsConnection' // The state behind the share dialog, shared by the desktop dialog and the one the // phone opens from its More sheet. The questions ("Remove this member?") are the @@ -74,6 +75,7 @@ export function useShareLink(tripId: number) { const url = token ? `${window.location.origin}/shared/${token}` : null const create = async () => { + if (refusedOffline(toast, t)) return try { const d = await shareApi.createLink(tripId, perms) setToken(d.token) @@ -82,6 +84,7 @@ export function useShareLink(tripId: number) { const setPerm = async (key: SharePerm, value: boolean) => { const next = { ...perms, [key]: value } + if (token && refusedOffline(toast, t)) return setPerms(next) if (token) { try { await shareApi.createLink(tripId, next) } catch { toast.error(t('share.createError')) } @@ -89,6 +92,7 @@ export function useShareLink(tripId: number) { } const remove = async () => { + if (refusedOffline(toast, t)) return try { await shareApi.deleteLink(tripId) setToken(null) @@ -117,6 +121,7 @@ export function useTripInviteLink(tripId: number) { const url = token ? `${window.location.origin}/join/${token}` : null const create = async () => { + if (refusedOffline(toast, t)) return setBusy(true) try { const d = await tripInviteApi.createLink(tripId); setToken(d.token) } catch { toast.error(t('share.createError')) } @@ -124,6 +129,7 @@ export function useTripInviteLink(tripId: number) { } const remove = async () => { + if (refusedOffline(toast, t)) return setBusy(true) try { await tripInviteApi.deleteLink(tripId); setToken(null) } catch { toast.error(t('common.error')) } @@ -184,7 +190,7 @@ export function useTripMembers({ isOpen, tripId, onClose, onMembersChanged }: { const add = async () => { const target = allUsers.find(u => String(u.id) === String(selectedUserId)) - if (!target) return + if (!target || refusedOffline(toast, t)) return setAdding(true) try { await tripsApi.addMember(tripId, target.username) @@ -199,6 +205,7 @@ export function useTripMembers({ isOpen, tripId, onClose, onMembersChanged }: { } const transfer = async (newOwnerId: number) => { + if (refusedOffline(toast, t)) return setTransferringId(newOwnerId) try { await tripsApi.transferOwnership(tripId, newOwnerId) @@ -213,6 +220,7 @@ export function useTripMembers({ isOpen, tripId, onClose, onMembersChanged }: { } const remove = async (userId: number, isSelf: boolean) => { + if (refusedOffline(toast, t)) return setRemovingId(userId) try { await tripsApi.removeMember(tripId, userId) @@ -227,7 +235,7 @@ export function useTripMembers({ isOpen, tripId, onClose, onMembersChanged }: { const addGuest = async () => { const name = newGuestName.trim() - if (!name) return + if (!name || refusedOffline(toast, t)) return setAddingGuest(true) try { await tripsApi.createGuest(tripId, name) @@ -262,6 +270,7 @@ export function useTripMembers({ isOpen, tripId, onClose, onMembersChanged }: { renameCommitted.current = true const name = renameValue.trim() if (!name) { setRenamingGuestId(null); return } + if (refusedOffline(toast, t)) { renameCommitted.current = false; return } try { await tripsApi.renameGuest(tripId, userId, name) setRenamingGuestId(null) @@ -273,6 +282,7 @@ export function useTripMembers({ isOpen, tripId, onClose, onMembersChanged }: { } const removeGuest = async (userId: number) => { + if (refusedOffline(toast, t)) return setRemovingId(userId) try { await tripsApi.deleteGuest(tripId, userId) diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index 53bcc8ef3..93a80a477 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -17,8 +17,10 @@ beforeEach(() => { // 2026-07-01..07-10) so the dashboard's past/upcoming/spotlight split is stable // regardless of the wall clock — otherwise these tests break once the date // rolls past the hardcoded fixture window. shouldAdvanceTime keeps userEvent - // and async waitFor working under fake timers. - vi.useFakeTimers({ shouldAdvanceTime: true }); + // and async waitFor working under fake timers. Only the clock is faked: the + // IndexedDB stand-in schedules its requests on timers, and a fake one left + // pending when a test ends stalls every later write to the offline cache. + vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['Date'] }); vi.setSystemTime(new Date('2026-07-05T12:00:00Z')); resetAllStores(); // Seed auth with authenticated user diff --git a/client/src/pages/dashboard/useDashboard.ts b/client/src/pages/dashboard/useDashboard.ts index 9bc7f2acd..b72df493e 100644 --- a/client/src/pages/dashboard/useDashboard.ts +++ b/client/src/pages/dashboard/useDashboard.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router' import { tripsApi, authApi, reservationsApi } from '../../api/client' import { tripRepo } from '../../repo/tripRepo' +import { isEffectivelyOffline } from '../../sync/networkMode' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { useToast } from '../../components/shared/Toast' @@ -132,7 +133,7 @@ export function useDashboard() { const handleUpdate = async (tripData: TripCreateRequest) => { if (!editingTrip) return try { - const data = await tripsApi.update(editingTrip.id, tripData) + const data = await tripRepo.update(editingTrip.id, tripData) setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t))) toast.success(t('dashboard.toast.updated')) } catch (err: unknown) { @@ -144,6 +145,9 @@ export function useDashboard() { // than only that it failed; the generic line stays for a failure without one. const confirmDelete = async () => { if (!deleteTrip) return + // Deleting, copying and creating a trip are the server's to do; archiving + // and editing its name wait in the offline queue. + if (isEffectivelyOffline()) { toast.error(t('common.needsConnection')); setDeleteTrip(null); return } try { await tripsApi.delete(deleteTrip.id) setTrips(prev => prev.filter(t => t.id !== deleteTrip.id)) @@ -157,7 +161,7 @@ export function useDashboard() { const handleArchive = async (id: number) => { try { - const data = await tripsApi.archive(id) + const data = await tripRepo.update(id, { is_archived: true }) setTrips(prev => prev.filter(t => t.id !== id)) setArchivedTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.archived')) @@ -168,7 +172,7 @@ export function useDashboard() { const handleUnarchive = async (id: number) => { try { - const data = await tripsApi.unarchive(id) + const data = await tripRepo.update(id, { is_archived: false }) setArchivedTrips(prev => prev.filter(t => t.id !== id)) setTrips(prev => sortTrips([data.trip, ...prev])) toast.success(t('dashboard.toast.restored')) @@ -179,6 +183,7 @@ export function useDashboard() { const confirmCopy = async () => { if (!copyTrip) return + if (isEffectivelyOffline()) { toast.error(t('common.needsConnection')); setCopyTrip(null); return } try { const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` }) setTrips(prev => sortTrips([data.trip, ...prev])) diff --git a/client/src/pages/tripPlanner/useDayPlanEdits.ts b/client/src/pages/tripPlanner/useDayPlanEdits.ts index 3bd7885b9..24bc2e473 100644 --- a/client/src/pages/tripPlanner/useDayPlanEdits.ts +++ b/client/src/pages/tripPlanner/useDayPlanEdits.ts @@ -3,6 +3,7 @@ import { reanchorAfterRemove, reanchorByStopOrder } from '../../components/Roadt import type { useRouteCalculation } from '../../hooks/useRouteCalculation' import type { useTourPlaceIds } from '../../hooks/useTourPlaceIds' import { useTripStore, type TripStoreState } from '../../store/tripStore' +import { isEffectivelyOffline } from '../../sync/networkMode' import type { Accommodation, Place } from '../../types' import { timedSlot } from '../../utils/dayMerge' import { restoreVisit, visitOf } from './placeUndo' @@ -164,6 +165,9 @@ export function useDayPlanEdits(options: DayPlanEditsOptions) { }, [tripId, toast]) const handleReorderDays = useCallback((orderedIds: number[]) => { + // The server moves the bookings' dates along with their days, so a new order + // of the days needs a connection, like adding or deleting one. + if (isEffectivelyOffline()) { toast.error(t('dayplan.daysOffline')); return } const prevIds = (useTripStore.getState().days || []) .slice().sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)).map(d => d.id) tripActions.reorderDays(tripId, orderedIds) diff --git a/client/src/repo/assignmentRepo.offline.test.ts b/client/src/repo/assignmentRepo.offline.test.ts new file mode 100644 index 000000000..3d1dd13c6 --- /dev/null +++ b/client/src/repo/assignmentRepo.offline.test.ts @@ -0,0 +1,152 @@ +// FE-REPO-ASSIGN-OFF-001 to FE-REPO-ASSIGN-OFF-007 +// Offline writes of a day's visits (#2601): put on a day, taken off, reordered, +// moved, who joins and how a leg is travelled. +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildAssignment, buildDay, 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 { assignmentRepo } from './assignmentRepo'; + +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); +} + +const place = buildPlace({ id: 300, trip_id: 4, name: 'Pantheon' }); +const first = buildAssignment({ id: 500, day_id: 70, order_index: 0, place: buildPlace({ id: 301, trip_id: 4 }) }); + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setOnline(false); + await offlineDb.days.bulkPut([ + buildDay({ id: 70, trip_id: 4, assignments: [first] }), + buildDay({ id: 71, trip_id: 4, assignments: [] }), + ]); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('assignmentRepo, offline', () => { + it('FE-REPO-ASSIGN-OFF-001: a place put on a day lands there under a temp id and the POST waits', async () => { + const visit = await assignmentRepo.create(4, 70, place); + + expect(visit.id).toBeLessThan(0); + expect(visit).toMatchObject({ day_id: 70, place_id: 300, order_index: 1, place: { name: 'Pantheon' } }); + expect((await offlineDb.days.get(70))?.assignments?.map((a) => a.id)).toEqual([500, visit.id]); + expect((await queue())[0]).toMatchObject({ + method: 'POST', + url: '/trips/4/days/70/assignments', + body: { place_id: 300 }, + resource: 'assignments', + tempId: visit.id, + }); + }); + + it('FE-REPO-ASSIGN-OFF-002: a place created offline is put on a day through the queue even online', async () => { + setOnline(true); + vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => {}); + const visit = await assignmentRepo.create(4, 70, buildPlace({ id: -42, trip_id: 4 })); + + expect(visit.id).toBeLessThan(0); + expect((await queue())[0]).toMatchObject({ method: 'POST', body: { place_id: -42 } }); + }); + + it('FE-REPO-ASSIGN-OFF-003: taking an unsynced visit off waits for its real id', async () => { + const visit = await assignmentRepo.create(4, 70, place); + await assignmentRepo.remove(4, 70, visit.id); + + expect((await offlineDb.days.get(70))?.assignments?.map((a) => a.id)).toEqual([500]); + expect((await queue())[1]).toMatchObject({ + method: 'DELETE', + url: '/trips/4/days/70/assignments/{id}', + tempEntityId: visit.id, + resource: 'assignments', + }); + }); + + it('FE-REPO-ASSIGN-OFF-004: a new order is cached and queued with the temp ids in it', async () => { + const visit = await assignmentRepo.create(4, 70, place); + await assignmentRepo.reorder(4, 70, [visit.id, 500]); + + expect((await offlineDb.days.get(70))?.assignments?.map((a) => [a.id, a.order_index])).toEqual([ + [visit.id, 0], + [500, 1], + ]); + expect((await queue())[1]).toMatchObject({ + method: 'PUT', + url: '/trips/4/days/70/assignments/reorder', + body: { orderedIds: [visit.id, 500] }, + }); + }); + + it('FE-REPO-ASSIGN-OFF-005: a move takes the visit to the other day and queues the move, then the order there', async () => { + await offlineDb.days.update(71, { assignments: [buildAssignment({ id: 510, day_id: 71, order_index: 0 })] }); + + await assignmentRepo.move(4, first, 71, 0, [500, 510]); + + expect((await offlineDb.days.get(70))?.assignments).toEqual([]); + expect((await offlineDb.days.get(71))?.assignments?.map((a) => [a.id, a.day_id, a.order_index])).toEqual([ + [500, 71, 0], + [510, 71, 1], + ]); + const [move, order] = await queue(); + expect(move).toMatchObject({ + url: '/trips/4/assignments/500/move', + body: { new_day_id: 71, order_index: 0 }, + entityId: 500, + }); + expect(order).toMatchObject({ url: '/trips/4/days/71/assignments/reorder', body: { orderedIds: [500, 510] } }); + }); + + it('FE-REPO-ASSIGN-OFF-006: who joins a visit is named from the cached members of the trip', async () => { + await offlineDb.tripMembers.bulkPut([ + { tripId: 4, id: 3, username: 'maurice', avatar: null }, + { tripId: 4, id: 5, username: 'tom', avatar: 'tom.png' }, + ]); + + const participants = await assignmentRepo.setParticipants(4, 500, [5]); + + expect(participants).toEqual([{ user_id: 5, username: 'tom', avatar: 'tom.png' }]); + expect((await offlineDb.days.get(70))?.assignments?.[0].participants).toEqual(participants); + expect((await queue())[0]).toMatchObject({ url: '/trips/4/assignments/500/participants', body: { user_ids: [5] } }); + }); + + it('FE-REPO-ASSIGN-OFF-007: the travel mode of the leg entering a visit is cached and queued with its direction', async () => { + await assignmentRepo.setLegMode(4, 500, 'driving', 'incoming'); + + expect((await offlineDb.days.get(70))?.assignments?.[0].incoming_leg_transport_mode).toBe('driving'); + expect((await queue())[0]).toMatchObject({ + url: '/trips/4/assignments/500/transport', + body: { transport_mode: 'driving', direction: 'incoming' }, + entityId: 500, + }); + }); + + it('FE-REPO-ASSIGN-OFF-008: online with nothing queued, a place goes on its day straight away', async () => { + setOnline(true); + server.use( + http.post('/api/trips/4/days/70/assignments', () => + HttpResponse.json({ assignment: buildAssignment({ id: 520, day_id: 70, order_index: 1, place }) }) + ) + ); + + const visit = await assignmentRepo.create(4, 70, place); + + expect(visit.id).toBe(520); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect((await offlineDb.days.get(70))?.assignments?.map((a) => a.id)).toEqual([500, 520]); + }); +}); diff --git a/client/src/repo/assignmentRepo.ts b/client/src/repo/assignmentRepo.ts index 6741e7d5b..a2c1a51b4 100644 --- a/client/src/repo/assignmentRepo.ts +++ b/client/src/repo/assignmentRepo.ts @@ -3,8 +3,9 @@ import { saveAssignmentEndDay } from '../api/assignmentEndDay' import { assignmentsApi } from '../api/client' import { offlineDb } from '../db/offlineDb' import { cacheAssignment } from '../db/cacheAssignment' -import { generateUUID, mutationQueue } from '../sync/mutationQueue' -import type { Assignment } from '../types' +import { generateUUID, mutationQueue, nextTempId } from '../sync/mutationQueue' +import { isEffectivelyOffline } from '../sync/networkMode' +import type { Assignment, AssignmentParticipant, Place } from '../types' /** Start and End of one visit, as the time route stores them: null is no time. */ export interface AssignmentTimes { @@ -12,7 +13,205 @@ export interface AssignmentTimes { end_time: string | null } +/** The url of a visit's own routes, with `{id}` for one created offline that has no server id yet. */ +function visitUrl(tripId: number | string, id: number, suffix: string): string { + return `/trips/${tripId}/assignments/${id < 0 ? '{id}' : id}/${suffix}` +} + +/** Ties a queued write to its visit, by the temporary id while the visit waits for its own. */ +function visitTarget(id: number): { resource: 'assignments'; entityId: number; tempEntityId?: number } { + return { resource: 'assignments', entityId: id, ...(id < 0 ? { tempEntityId: id } : {}) } +} + +/** Lays fields over the cached visit, on whichever day of the trip holds it. */ +async function cacheVisitFields(tripId: number, id: number, fields: Partial): Promise { + await offlineDb.days.where('trip_id').equals(tripId).modify(day => { + if (!day.assignments?.some(a => a.id === id)) return + day.assignments = day.assignments.map(a => (a.id === id ? { ...a, ...fields } : a)) + }) +} + +/** The day's visits in the order given; the ones not named keep their place behind them. */ +function inOrder(items: Assignment[], orderedIds: number[]): Assignment[] { + const rank = new Map(orderedIds.map((id, index) => [id, index])) + return items + .map(a => { + const index = rank.get(a.id) + return index === undefined ? a : { ...a, order_index: index } + }) + .sort((a, b) => a.order_index - b.order_index) +} + +/** + * A new order of a day's visits joins the queue offline, and online too while a + * visit in it has no server id yet: sent at once it would name a temporary id + * the server does not know and overtake the create still waiting. The replay + * sends the real ids the queue learned by then. + */ +async function queueReorder(tripId: number | string, dayId: number, orderedIds: number[]): Promise { + await mutationQueue.enqueue({ + id: generateUUID(), tripId: Number(tripId), method: 'PUT', + url: `/trips/${tripId}/days/${dayId}/assignments/reorder`, + body: { orderedIds }, + }) +} + export const assignmentRepo = { + /** + * Puts a place on a day. Offline the visit lands on the cached day under a + * temporary id at the end, and the create waits in the queue; a place created + * offline is named by its real id once that one has synced. + */ + async create(tripId: number | string, dayId: number, place: Place): Promise { + if (!isEffectivelyOffline() && place.id > 0) { + const { assignment } = await assignmentsApi.create(tripId, dayId, { place_id: place.id }) + const saved = { ...assignment, place: assignment.place || place } as Assignment + await offlineDb.days.where('id').equals(dayId).modify(day => { + day.assignments = [...(day.assignments ?? []).filter(a => a.id !== saved.id), saved] + }) + return saved + } + const tempId = nextTempId() + let visit = { id: tempId, day_id: dayId, place_id: place.id, order_index: 0, notes: null, place } as Assignment + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await offlineDb.days.where('id').equals(dayId).modify(day => { + const items = day.assignments ?? [] + visit = { ...visit, order_index: items.reduce((max, a) => Math.max(max, a.order_index), -1) + 1 } + day.assignments = [...items, visit] + }) + await mutationQueue.enqueue({ + id: generateUUID(), tripId: Number(tripId), method: 'POST', + url: `/trips/${tripId}/days/${dayId}/assignments`, + body: { place_id: place.id }, resource: 'assignments', tempId, + }) + }) + mutationQueue.sendSoon() + return visit + }, + + /** Takes a visit off its day, queued behind any older write to it. */ + async remove(tripId: number | string, dayId: number, id: number): Promise { + const dropCached = () => offlineDb.days.where('id').equals(dayId).modify(day => { + day.assignments = (day.assignments ?? []).filter(a => a.id !== id) + }) + if (!(await mutationQueue.mustQueue('assignments', id))) { + await assignmentsApi.delete(tripId, dayId, id) + await dropCached() + return + } + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await mutationQueue.enqueue({ + id: generateUUID(), tripId: Number(tripId), method: 'DELETE', + url: `/trips/${tripId}/days/${dayId}/assignments/${id < 0 ? '{id}' : id}`, + body: undefined, ...visitTarget(id), + }) + await dropCached() + }) + mutationQueue.sendSoon() + }, + + /** The order of a day's visits. */ + async reorder(tripId: number | string, dayId: number, orderedIds: number[]): Promise { + const cacheOrder = () => offlineDb.days.where('id').equals(dayId).modify(day => { + day.assignments = inOrder(day.assignments ?? [], orderedIds) + }) + if (!isEffectivelyOffline() && orderedIds.every(id => id > 0)) { + await assignmentsApi.reorder(tripId, dayId, orderedIds) + await cacheOrder() + return + } + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await queueReorder(tripId, dayId, orderedIds) + await cacheOrder() + }) + mutationQueue.sendSoon() + }, + + /** + * Moves a visit to another day at `orderIndex`, then puts that day in the + * order given (the moved visit among them). Offline both wait in the queue, + * in that order. + */ + async move(tripId: number | string, assignment: Assignment, toDayId: number, orderIndex: number, orderedIds: number[]): Promise { + const cacheMove = async () => { + await offlineDb.days.where('trip_id').equals(Number(tripId)).modify(day => { + if (day.id === toDayId) return + day.assignments = (day.assignments ?? []).filter(a => a.id !== assignment.id) + }) + await offlineDb.days.where('id').equals(toDayId).modify(day => { + const others = (day.assignments ?? []).filter(a => a.id !== assignment.id) + day.assignments = inOrder([...others, { ...assignment, day_id: toDayId }], orderedIds) + }) + } + if (!(await mutationQueue.mustQueue('assignments', assignment.id)) && orderedIds.every(id => id > 0)) { + await assignmentsApi.move(tripId, assignment.id, toDayId, orderIndex) + if (orderedIds.length > 1) await assignmentsApi.reorder(tripId, toDayId, orderedIds) + await cacheMove() + return + } + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await mutationQueue.enqueue({ + id: generateUUID(), tripId: Number(tripId), method: 'PUT', + url: visitUrl(tripId, assignment.id, 'move'), + body: { new_day_id: toDayId, order_index: orderIndex }, ...visitTarget(assignment.id), + }) + if (orderedIds.length > 1) await queueReorder(tripId, toDayId, orderedIds) + await cacheMove() + }) + mutationQueue.sendSoon() + }, + + /** + * Who joins a visit. Offline the names come from the cached members of the + * trip, which is where the server takes them from too. + */ + async setParticipants(tripId: number | string, id: number, userIds: number[]): Promise { + const tid = Number(tripId) + if (!(await mutationQueue.mustQueue('assignments', id))) { + const { participants } = (await assignmentsApi.setParticipants(tripId, id, userIds)) as { participants: AssignmentParticipant[] } + await cacheVisitFields(tid, id, { participants }) + return participants + } + const members = await offlineDb.tripMembers.where('tripId').equals(tid).toArray() + const participants: AssignmentParticipant[] = members + .filter(m => userIds.includes(m.id)) + .map(m => ({ user_id: m.id, username: m.username, avatar: m.avatar ?? null })) + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await mutationQueue.enqueue({ + id: generateUUID(), tripId: tid, method: 'PUT', + url: visitUrl(tripId, id, 'participants'), + body: { user_ids: userIds }, ...visitTarget(id), + }) + await cacheVisitFields(tid, id, { participants }) + }) + mutationQueue.sendSoon() + return participants + }, + + /** The travel mode of the leg leaving a visit, or with 'incoming' of the one entering it. */ + async setLegMode(tripId: number | string, id: number, mode: string | null, direction: 'outgoing' | 'incoming' = 'outgoing'): Promise { + const tid = Number(tripId) + const fields: Partial = direction === 'incoming' ? { incoming_leg_transport_mode: mode } : { leg_transport_mode: mode } + if (!(await mutationQueue.mustQueue('assignments', id))) { + // The outgoing leg is sent without a direction, the shape the route has always had. + await (direction === 'incoming' + ? assignmentsApi.updateTransport(tripId, id, mode, direction) + : assignmentsApi.updateTransport(tripId, id, mode)) + await cacheVisitFields(tid, id, fields) + return + } + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await mutationQueue.enqueue({ + id: generateUUID(), tripId: tid, method: 'PUT', + url: visitUrl(tripId, id, 'transport'), + body: direction === 'incoming' ? { transport_mode: mode, direction } : { transport_mode: mode }, + ...visitTarget(id), + }) + await cacheVisitFields(tid, id, fields) + }) + mutationQueue.sendSoon() + }, + /** * Empties a day (#2470). Offline the cached day loses its places right away and the * clear waits in the queue, where the server applies it to whatever the day holds on diff --git a/client/src/repo/dayNoteRepo.offline.test.ts b/client/src/repo/dayNoteRepo.offline.test.ts new file mode 100644 index 000000000..c4bca9aed --- /dev/null +++ b/client/src/repo/dayNoteRepo.offline.test.ts @@ -0,0 +1,167 @@ +// FE-REPO-DAYNOTE-OFF-001 to FE-REPO-DAYNOTE-OFF-005, FE-REPO-DAY-OFF-001 to FE-REPO-DAY-OFF-004 +// Offline writes of a day and its notes (#2601). +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildDay, buildDayNote } 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 { dayNoteRepo } from './dayNoteRepo'; +import { dayRepo } from './dayRepo'; + +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); + await offlineDb.days.put( + buildDay({ id: 70, trip_id: 4, title: 'Old', notes_items: [buildDayNote({ id: 900, day_id: 70, text: 'Museum' })] }) + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('dayNoteRepo, offline', () => { + it('FE-REPO-DAYNOTE-OFF-001: a new note lands on the cached day under a temp id and a POST waits in the queue', async () => { + const { note } = await dayNoteRepo.create(4, 70, { text: 'Lunch', time: '12:00', color: null }); + + expect(note.id).toBeLessThan(0); + expect(note).toMatchObject({ day_id: 70, text: 'Lunch', time: '12:00' }); + expect((await offlineDb.days.get(70))?.notes_items?.map((n) => n.text)).toEqual(['Museum', 'Lunch']); + const [mutation] = await queue(); + expect(mutation).toMatchObject({ + method: 'POST', + url: '/trips/4/days/70/notes', + resource: 'dayNotes', + tempId: note.id, + body: { text: 'Lunch', time: '12:00', color: null }, + }); + }); + + it('FE-REPO-DAYNOTE-OFF-002: an edit and a delete of an unsynced note wait for its real id', async () => { + const { note } = await dayNoteRepo.create(4, 70, { text: 'Lunch' }); + const { note: edited } = await dayNoteRepo.update(4, 70, note, { text: 'Brunch' }); + expect(edited).toMatchObject({ id: note.id, text: 'Brunch' }); + await dayNoteRepo.delete(4, 70, note.id); + + expect((await offlineDb.days.get(70))?.notes_items?.map((n) => n.id)).toEqual([900]); + const [, edit, del] = await queue(); + expect(edit).toMatchObject({ + method: 'PUT', + url: '/trips/4/days/70/notes/{id}', + tempEntityId: note.id, + resource: 'dayNotes', + }); + expect(del).toMatchObject({ method: 'DELETE', url: '/trips/4/days/70/notes/{id}', tempEntityId: note.id }); + }); + + it('FE-REPO-DAYNOTE-OFF-003: an edit of a synced note shows the whole note and names it in the url', async () => { + const before = buildDayNote({ id: 900, day_id: 70, text: 'Museum', time: '10:00' }); + const { note } = await dayNoteRepo.update(4, 70, before, { time: '11:00' }); + + expect(note).toMatchObject({ id: 900, text: 'Museum', time: '11:00' }); + expect((await offlineDb.days.get(70))?.notes_items?.[0]).toMatchObject({ time: '11:00' }); + expect((await queue())[0]).toMatchObject({ url: '/trips/4/days/70/notes/900', entityId: 900 }); + }); + + it('FE-REPO-DAYNOTE-OFF-004: online, an edit joins the queue while an older write to the note waits', async () => { + await dayNoteRepo.delete(4, 70, 900); + setOnline(true); + vi.spyOn(mutationQueue, 'sendSoon').mockImplementation(() => {}); + const put = vi.fn(); + server.use( + http.put('/api/trips/4/days/70/notes/900', () => { + put(); + return HttpResponse.json({}); + }) + ); + + await dayNoteRepo.update(4, 70, buildDayNote({ id: 900, day_id: 70 }), { text: 'x' }); + + expect(put).not.toHaveBeenCalled(); + expect((await queue()).map((m) => m.method)).toEqual(['DELETE', 'PUT']); + }); + + it('FE-REPO-DAYNOTE-OFF-005: online, a new note goes to the server and onto the cached day', async () => { + setOnline(true); + server.use( + http.post('/api/trips/4/days/70/notes', () => + HttpResponse.json({ note: buildDayNote({ id: 901, day_id: 70, text: 'Dinner' }) }) + ) + ); + + const { note } = await dayNoteRepo.create(4, 70, { text: 'Dinner' }); + await new Promise((r) => setTimeout(r, 0)); + + expect(note.id).toBe(901); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect((await offlineDb.days.get(70))?.notes_items?.map((n) => n.id)).toEqual([900, 901]); + }); +}); + +describe('dayRepo, offline', () => { + it('FE-REPO-DAY-OFF-001: a new day title is cached at once and the PUT waits in the queue', async () => { + await dayRepo.update(4, 70, { title: 'Arrival' }); + + expect(await offlineDb.days.get(70)).toMatchObject({ + title: 'Arrival', + notes_items: [expect.objectContaining({ id: 900 })], + }); + expect((await queue())[0]).toMatchObject({ + method: 'PUT', + url: '/trips/4/days/70', + body: { title: 'Arrival' }, + resource: 'days', + entityId: 70, + }); + }); + + it('FE-REPO-DAY-OFF-002: the travel mode of a day waits in the queue on its own route', async () => { + await dayRepo.setTransport(4, 70, 'cycling'); + + expect((await offlineDb.days.get(70))?.default_transport_mode).toBe('cycling'); + expect((await queue())[0]).toMatchObject({ + url: '/trips/4/days/70/transport', + body: { transport_mode: 'cycling' }, + resource: 'days', + }); + }); + + it('FE-REPO-DAY-OFF-003: a new order of the days and a new empty day need a connection', async () => { + await expect(dayRepo.reorder(4, [70])).rejects.toThrow('needs a connection'); + await expect(dayRepo.insert(4, 1)).rejects.toThrow('needs a connection'); + expect(await offlineDb.mutationQueue.count()).toBe(0); + }); + + it('FE-REPO-DAY-OFF-004: online with nothing queued, a title goes straight to the server', async () => { + setOnline(true); + const put = vi.fn(); + server.use( + http.put('/api/trips/4/days/70', async ({ request }) => { + put(await request.json()); + return HttpResponse.json({ day: buildDay({ id: 70, trip_id: 4, title: 'Arrival' }) }); + }) + ); + + await dayRepo.update(4, 70, { title: 'Arrival' }); + await new Promise((r) => setTimeout(r, 0)); + + expect(put).toHaveBeenCalledWith({ title: 'Arrival' }); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect((await offlineDb.days.get(70))?.title).toBe('Arrival'); + }); +}); diff --git a/client/src/repo/dayNoteRepo.ts b/client/src/repo/dayNoteRepo.ts new file mode 100644 index 000000000..5c350431a --- /dev/null +++ b/client/src/repo/dayNoteRepo.ts @@ -0,0 +1,130 @@ +import type { DayNoteCreateRequest, DayNoteUpdateRequest } from '@trek/shared'; +import { dayNotesApi } from '../api/client'; +import { offlineDb } from '../db/offlineDb'; +import { generateUUID, mutationQueue, nextTempId } from '../sync/mutationQueue'; +import { isEffectivelyOffline } from '../sync/networkMode'; +import type { DayNote } from '../types'; +import { cacheAfterWrite } from './dayRepo'; + +/** + * The notes of a day live on the cached day (`notes_items`), the way the day + * list answers them. Writes the server already took land there too, without + * waiting: once the server has the note, a failing IndexedDB must not report + * the save as failed. + */ +function cacheNote(dayId: number, note: DayNote, replaceId: number = note.id): Promise { + return offlineDb.days + .where('id') + .equals(dayId) + .modify((day) => { + const notes = day.notes_items ?? []; + day.notes_items = notes.some((n) => n.id === replaceId) + ? notes.map((n) => (n.id === replaceId ? { ...n, ...note } : n)) + : [...notes, note]; + }) + .then(() => undefined); +} + +function uncacheNote(dayId: number, noteId: number): Promise { + return offlineDb.days + .where('id') + .equals(dayId) + .modify((day) => { + day.notes_items = (day.notes_items ?? []).filter((n) => n.id !== noteId); + }) + .then(() => undefined); +} + +function noteUrl(tripId: number | string, dayId: number, id: number): string { + return `/trips/${tripId}/days/${dayId}/notes/${id < 0 ? '{id}' : id}`; +} + +/** Queue a write to one note, held behind any older write to it (a create offline included). */ +async function enqueueNoteWrite( + tripId: number | string, + dayId: number, + id: number, + method: 'PUT' | 'DELETE', + body: unknown +): Promise { + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method, + url: noteUrl(tripId, dayId, id), + body, + resource: 'dayNotes', + entityId: id, + ...(id < 0 ? { tempEntityId: id } : {}), + }); + mutationQueue.sendSoon(); +} + +/** + * The notes of a day through the offline core. A note written offline lands on + * the cached day and in the mutation queue and replays once the connection is + * back. Notes carry no version token, so an online write to a note that still + * has an older one queued joins the queue behind it (mustQueue). + */ +export const dayNoteRepo = { + async create(tripId: number | string, dayId: number, data: DayNoteCreateRequest): Promise<{ note: DayNote }> { + if (!isEffectivelyOffline()) { + const result: { note: DayNote } = await dayNotesApi.create(tripId, dayId, data); + cacheAfterWrite(cacheNote(dayId, result.note), 'adding a day note'); + return result; + } + const tempId = nextTempId(); + const note: DayNote = { + id: tempId, + day_id: dayId, + trip_id: Number(tripId), + text: data.text, + time: data.time ?? null, + icon: data.icon ?? null, + color: data.color ?? null, + ...(data.sort_order === undefined ? {} : { sort_order: data.sort_order }), + created_at: new Date().toISOString(), + }; + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await cacheNote(dayId, note); + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'POST', + url: `/trips/${tripId}/days/${dayId}/notes`, + body: data, + resource: 'dayNotes', + tempId, + }); + }); + return { note }; + }, + + /** `before` is the note as it stands, so an offline edit can show the whole note. */ + async update( + tripId: number | string, + dayId: number, + before: DayNote, + data: DayNoteUpdateRequest + ): Promise<{ note: DayNote }> { + if (!(await mutationQueue.mustQueue('dayNotes', before.id))) { + const result: { note: DayNote } = await dayNotesApi.update(tripId, dayId, before.id, data); + cacheAfterWrite(cacheNote(dayId, result.note), 'editing a day note'); + return result; + } + const note = { ...before, ...data } as DayNote; + await cacheNote(dayId, note); + await enqueueNoteWrite(tripId, dayId, before.id, 'PUT', data); + return { note }; + }, + + async delete(tripId: number | string, dayId: number, id: number): Promise { + if (!(await mutationQueue.mustQueue('dayNotes', id))) { + await dayNotesApi.delete(tripId, dayId, id); + cacheAfterWrite(uncacheNote(dayId, id), 'deleting a day note'); + return; + } + await uncacheNote(dayId, id); + await enqueueNoteWrite(tripId, dayId, id, 'DELETE', undefined); + }, +}; diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts index b00bf6208..881b42ca4 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -1,14 +1,35 @@ import { daysApi } from '../api/client' import { offlineDb, replaceTripRows, upsertTrip } from '../db/offlineDb' +import { generateUUID, mutationQueue } from '../sync/mutationQueue' import { isEffectivelyOffline } from '../sync/networkMode' import { onlineThenCache } from './withOfflineFallback' import type { Day, Trip } from '../types' +import type { DayUpdateRequest } from '@trek/shared' /** A cache write after the server already answered: its failure is logged, not the caller's. */ -function cacheAfterWrite(write: Promise, what: string): void { +export function cacheAfterWrite(write: Promise, what: string): void { write.catch((err: unknown) => console.warn(`Offline cache not updated after ${what}:`, err)) } +/** + * The cached day with the fields of a write laid over it. The day answers carry + * no notes of the day, so the ones the cache holds stay. + */ +async function cacheDayFields(dayId: number, fields: Partial): Promise { + await offlineDb.days.where('id').equals(dayId).modify(day => { Object.assign(day, fields) }) +} + +/** Queue a write to one day, held behind any older write to it. */ +async function enqueueDayWrite(tripId: number | string, dayId: number, url: string, body: unknown, fields: Partial): Promise { + await offlineDb.transaction('rw', offlineDb.days, offlineDb.mutationQueue, async () => { + await mutationQueue.enqueue({ + id: generateUUID(), tripId: Number(tripId), method: 'PUT', url, body, resource: 'days', entityId: dayId, + }) + await cacheDayFields(dayId, fields) + }) + mutationQueue.sendSoon() +} + export const dayRepo = { async list(tripId: number | string): Promise<{ days: Day[] }> { return onlineThenCache( @@ -26,6 +47,35 @@ export const dayRepo = { ) }, + /** + * A day's title or its notes text. The server keeps the field a write leaves + * out, so each goes on its own. Day writes carry no version token, so offline, + * and online while an older write to the day still waits, it joins the queue. + */ + async update(tripId: number | string, dayId: number, data: DayUpdateRequest): Promise { + const fields: Partial = { + ...('title' in data ? { title: data.title ?? null } : {}), + ...('notes' in data ? { notes: data.notes || null } : {}), + } + if (!(await mutationQueue.mustQueue('days', dayId))) { + await daysApi.update(tripId, dayId, data) + cacheAfterWrite(cacheDayFields(dayId, fields), 'renaming a day') + return + } + await enqueueDayWrite(tripId, dayId, `/trips/${tripId}/days/${dayId}`, data, fields) + }, + + /** The whole-day default route mode, queued the same way as update. */ + async setTransport(tripId: number | string, dayId: number, mode: string | null): Promise { + const fields: Partial = { default_transport_mode: mode } + if (!(await mutationQueue.mustQueue('days', dayId))) { + await daysApi.updateTransport(tripId, dayId, mode) + cacheAfterWrite(cacheDayFields(dayId, fields), 'changing the travel mode of a day') + return + } + await enqueueDayWrite(tripId, dayId, `/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }, fields) + }, + /** * Delete a day. Online only, deliberately: the server cancels the stays on the * day, lets go of its bookings and moves the dates of the days after it, and a @@ -45,6 +95,24 @@ export const dayRepo = { return { trip: result.trip } }, + /** + * Put the days in a new order. Online only, like remove: on a trip with dates + * the server moves the dates of the bookings along with their days, which the + * cache cannot follow. + */ + async reorder(tripId: number | string, orderedIds: number[]): Promise { + if (isEffectivelyOffline()) throw new Error('Reordering days needs a connection') + await daysApi.reorder(tripId, orderedIds) + }, + + /** An empty day at a 1-based position, or at the end. Online only, like appendDated. */ + async insert(tripId: number | string, position?: number): Promise<{ day: Day; trip?: Trip }> { + if (isEffectivelyOffline()) throw new Error('Adding a day needs a connection') + const result = await daysApi.create(tripId, { position }) + if (result.trip) cacheAfterWrite(upsertTrip(result.trip), 'adding a day') + return result + }, + /** * Add the calendar day after the trip's last date, which extends the trip by * one day. Online only, like remove: the server picks the date and moves the diff --git a/client/src/repo/tripRepo.offline.test.ts b/client/src/repo/tripRepo.offline.test.ts new file mode 100644 index 000000000..c37830f33 --- /dev/null +++ b/client/src/repo/tripRepo.offline.test.ts @@ -0,0 +1,81 @@ +// FE-REPO-TRIP-OFF-001 to FE-REPO-TRIP-OFF-004 +// Offline changes to the trip itself (#2601). +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildTrip } from '../../tests/helpers/factories'; +import { server } from '../../tests/helpers/msw/server'; +import { clearAll, offlineDb } from '../db/offlineDb'; +import { mutationQueue } from '../sync/mutationQueue'; +import { tripChangeNeedsConnection, TripNeedsConnectionError, tripRepo } from './tripRepo'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +const trip = buildTrip({ id: 4, title: 'Rome', start_date: '2026-11-02', end_date: '2026-11-04', currency: 'EUR' }); + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setOnline(false); + await offlineDb.trips.put(trip); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setOnline(true); +}); + +describe('tripRepo.update, offline', () => { + it('FE-REPO-TRIP-OFF-001: a new name sent with the unchanged dates is cached and queued', async () => { + const { trip: saved } = await tripRepo.update(4, { + title: 'Roma', + description: 'Pasta', + start_date: '2026-11-02', + end_date: '2026-11-04', + currency: 'EUR', + reminder_days: 3, + }); + + expect(saved).toMatchObject({ id: 4, title: 'Roma', description: 'Pasta', reminder_days: 3 }); + expect(await offlineDb.trips.get(4)).toMatchObject({ title: 'Roma' }); + const [row] = await offlineDb.mutationQueue.toArray(); + expect(row).toMatchObject({ method: 'PUT', url: '/trips/4', resource: 'trips', entityId: 4 }); + }); + + it('FE-REPO-TRIP-OFF-002: new dates, a day count, a currency or a cover are refused and nothing is written', async () => { + for (const change of [{ end_date: '2026-11-06' }, { day_count: 5 }, { currency: 'USD' }, { cover_image: null }]) { + await expect(tripRepo.update(4, change)).rejects.toBeInstanceOf(TripNeedsConnectionError); + } + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect((await offlineDb.trips.get(4))?.end_date).toBe('2026-11-04'); + }); + + it('FE-REPO-TRIP-OFF-003: archiving offline waits in the queue', async () => { + await tripRepo.update(4, { is_archived: true }); + + expect((await offlineDb.trips.get(4))?.is_archived).toBe(true); + expect((await offlineDb.mutationQueue.toArray())[0].body).toEqual({ is_archived: true }); + }); + + it('FE-REPO-TRIP-OFF-004: online with nothing queued, a change goes straight to the server', async () => { + setOnline(true); + server.use(http.put('/api/trips/4', () => HttpResponse.json({ trip: { ...trip, end_date: '2026-11-06' } }))); + + const { trip: saved } = await tripRepo.update(4, { end_date: '2026-11-06' }); + + expect(saved.end_date).toBe('2026-11-06'); + expect(await offlineDb.mutationQueue.count()).toBe(0); + }); +}); + +describe('tripChangeNeedsConnection', () => { + it('FE-REPO-TRIP-OFF-005: a field sent with the value the trip has needs nothing', () => { + expect( + tripChangeNeedsConnection(trip, { title: 'x', start_date: '2026-11-02', end_date: '2026-11-04', currency: 'EUR' }) + ).toBe(false); + expect(tripChangeNeedsConnection(trip, { start_date: '2026-11-01' })).toBe(true); + expect(tripChangeNeedsConnection(undefined, { start_date: '2026-11-02' })).toBe(true); + }); +}); diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index b300cde14..4a5a456bf 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -1,8 +1,31 @@ import { tripsApi } from '../api/client' import { offlineDb, upsertTrip } from '../db/offlineDb' +import { generateUUID, mutationQueue } from '../sync/mutationQueue' +import { isEffectivelyOffline } from '../sync/networkMode' import { onlineThenCache } from './withOfflineFallback' import type { Trip } from '../types' -import type { ActiveTripResponse } from '@trek/shared' +import type { ActiveTripResponse, TripUpdateRequest } from '@trek/shared' + +/** A trip change refused offline because only the server can carry it out. */ +export class TripNeedsConnectionError extends Error { + constructor() { + super('Changing the dates, the number of days, the currency or the cover needs a connection') + this.name = 'TripNeedsConnectionError' + } +} + +/** + * Does this change need the server? New dates or a day count rebuild the days, + * another currency rebases the costs, a cover is stored there. A field sent + * with the value the trip already has changes nothing, so the edit form, which + * always sends every field, can still save a new name offline. + */ +export function tripChangeNeedsConnection(trip: Pick | undefined, data: TripUpdateRequest): boolean { + const differs = (key: 'start_date' | 'end_date' | 'currency') => + data[key] !== undefined && (!trip || (data[key] ?? null) !== (trip[key] ?? null)) + return differs('start_date') || differs('end_date') || differs('currency') + || data.day_count !== undefined || data.cover_image !== undefined +} /** * Offline stand-in for GET /trips/active. Mirrors the server's ranking @@ -71,6 +94,37 @@ export const tripRepo = { ) }, + /** + * A change to the trip itself. Its name, description, reminder and the archive + * flag wait in the queue offline, and online too while an older write to the + * trip still waits, since trip writes carry no version token. New dates, a new + * day count, another currency or a cover need the server (it rebuilds the days, + * rebases the costs or stores the image), so offline they are refused with + * TripNeedsConnectionError and nothing is written. + */ + async update(tripId: number | string, data: TripUpdateRequest): Promise<{ trip: Trip }> { + const id = Number(tripId) + if (!(await mutationQueue.mustQueue('trips', id))) { + const result: { trip: Trip } = await tripsApi.update(tripId, data) + const cached = await offlineDb.trips.get(id) + void upsertTrip({ ...cached, ...result.trip }) + return result + } + const cached = await offlineDb.trips.get(id) + if (isEffectivelyOffline() && tripChangeNeedsConnection(cached, data)) throw new TripNeedsConnectionError() + const { date_shift_mode: _mode, ...fields } = data + const trip = { ...(cached ?? { id }), ...fields } as Trip + await offlineDb.transaction('rw', offlineDb.trips, offlineDb.mutationQueue, async () => { + await offlineDb.trips.put(trip) + await mutationQueue.enqueue({ + id: generateUUID(), tripId: id, method: 'PUT', url: `/trips/${id}`, + body: data, resource: 'trips', entityId: id, + }) + }) + mutationQueue.sendSoon() + return { trip } + }, + async get(tripId: number | string): Promise<{ trip: Trip }> { return onlineThenCache( async () => { diff --git a/client/src/store/slices/assignmentsSlice.ts b/client/src/store/slices/assignmentsSlice.ts index e2149c100..9b8781e0f 100644 --- a/client/src/store/slices/assignmentsSlice.ts +++ b/client/src/store/slices/assignmentsSlice.ts @@ -1,10 +1,10 @@ -import { assignmentsApi } from '../../api/client' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { Assignment, AssignmentsMap } from '../../types' import { getApiErrorMessage } from '../../types' import { assignmentRepo, type AssignmentTimes } from '../../repo/assignmentRepo' import { mergeAssignmentPlace } from './placesSlice' +import { nextTempId } from '../../sync/mutationQueue' type SetState = StoreApi['setState'] type GetState = StoreApi['getState'] @@ -99,16 +99,16 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment throw err } }, - // Who joins the visit and how its legs are travelled are online writes, as they are - // from the place inspector and the leg menu; the answer is written into the day. + // Who joins the visit and how its legs are travelled; offline both wait in the + // queue. The participants the repo answers with are written into the day. setAssignmentParticipants: async (tripId, dayId, assignmentId, userIds) => { - const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds) + const participants = await assignmentRepo.setParticipants(tripId, assignmentId, userIds) set(state => ({ - assignments: { ...state.assignments, [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => a.id === assignmentId ? { ...a, participants: data.participants } : a) }, + assignments: { ...state.assignments, [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => a.id === assignmentId ? { ...a, participants } : a) }, })) }, setAssignmentLegMode: async (tripId, dayId, assignmentId, mode, direction = 'outgoing') => { - await assignmentsApi.updateTransport(tripId, assignmentId, mode, direction) + await assignmentRepo.setLegMode(tripId, assignmentId, mode, direction) const field = direction === 'incoming' ? 'incoming_leg_transport_mode' : 'leg_transport_mode' set(state => ({ assignments: { ...state.assignments, [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => a.id === assignmentId ? { ...a, [field]: mode } : a) }, @@ -119,7 +119,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment const place = state.places.find(p => p.id === Number.parseInt(String(placeId))) if (!place) return - const tempId = Date.now() * -1 + const tempId = nextTempId() // Sorted first, as moveAssignment does: the position is an index in the day as // it is shown, and that day is sorted by order_index. The stored list is not // always, a stop that arrived over the socket is appended wherever its @@ -145,11 +145,11 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment })) try { - const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId }) + const created = await assignmentRepo.create(tripId, Number(dayId), place) const newAssignment: Assignment = { - ...data.assignment, - place: data.assignment.place || place, - order_index: position != null ? insertIdx : data.assignment.order_index, + ...created, + place: created.place || place, + order_index: position != null ? insertIdx : created.order_index, } set(state => ({ assignments: { @@ -161,10 +161,10 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment })) if (position != null) { const updated = get().assignments[String(dayId)] || [] - const orderedIds = updated.map(a => a.id).filter(id => id > 0) + const orderedIds = updated.map(a => a.id) if (orderedIds.length > 0) { try { - await assignmentsApi.reorder(tripId, dayId, orderedIds) + await assignmentRepo.reorder(tripId, Number(dayId), orderedIds) set(state => { const items = state.assignments[String(dayId)] || [] const reordered = orderedIds.map((id, idx) => { @@ -181,7 +181,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment } catch {} } } - return data.assignment + return created } catch (err: unknown) { set(state => ({ assignments: { @@ -204,7 +204,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment })) try { - await assignmentsApi.delete(tripId, dayId, assignmentId) + await assignmentRepo.remove(tripId, Number(dayId), assignmentId) } catch (err: unknown) { set({ assignments: prevAssignments }) throw new Error(getApiErrorMessage(err, 'Error removing assignment')) @@ -238,7 +238,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment })) try { - await assignmentsApi.reorder(tripId, dayId, orderedIds) + await assignmentRepo.reorder(tripId, Number(dayId), orderedIds) } catch (err: unknown) { set({ assignments: prevAssignments }) throw new Error(getApiErrorMessage(err, 'Error reordering')) @@ -271,10 +271,7 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment })) try { - await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt) - if (newToItems.length > 1) { - await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id)) - } + await assignmentRepo.move(tripId, assignment, Number(toDayId), insertAt, newToItems.map(a => a.id)) } catch (err: unknown) { set({ assignments: prevAssignments }) throw new Error(getApiErrorMessage(err, 'Error moving assignment')) diff --git a/client/src/store/slices/dayNotesSlice.ts b/client/src/store/slices/dayNotesSlice.ts index 1d0eddb40..2900d221d 100644 --- a/client/src/store/slices/dayNotesSlice.ts +++ b/client/src/store/slices/dayNotesSlice.ts @@ -1,8 +1,10 @@ -import { daysApi, dayNotesApi } from '../../api/client' +import { dayRepo } from '../../repo/dayRepo' +import { dayNoteRepo } from '../../repo/dayNoteRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { DayNote } from '../../types' import { getApiErrorMessage } from '../../types' +import { nextTempId } from '../../sync/mutationQueue' type SetState = StoreApi['setState'] type GetState = StoreApi['getState'] @@ -19,7 +21,7 @@ export interface DayNotesSlice { export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({ updateDayNotes: async (tripId, dayId, notes) => { try { - await daysApi.update(tripId, dayId, { notes }) + await dayRepo.update(tripId, Number(dayId), { notes }) set(state => ({ days: state.days.map(d => d.id === Number.parseInt(String(dayId)) ? { ...d, notes } : d) })) @@ -30,7 +32,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice updateDayTitle: async (tripId, dayId, title) => { try { - await daysApi.update(tripId, dayId, { title }) + await dayRepo.update(tripId, Number(dayId), { title }) set(state => ({ days: state.days.map(d => d.id === Number.parseInt(String(dayId)) ? { ...d, title } : d) })) @@ -40,7 +42,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice }, addDayNote: async (tripId, dayId, data) => { - const tempId = Date.now() * -1 + const tempId = nextTempId() const tempNote: DayNote = { id: tempId, day_id: dayId as number, ...data, created_at: new Date().toISOString() } as DayNote set(state => ({ dayNotes: { @@ -49,7 +51,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice } })) try { - const result = await dayNotesApi.create(tripId, dayId, data) + const result = await dayNoteRepo.create(tripId, Number(dayId), data) set(state => ({ dayNotes: { ...state.dayNotes, @@ -70,7 +72,8 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice updateDayNote: async (tripId, dayId, id, data) => { try { - const result = await dayNotesApi.update(tripId, dayId, id, data) + const before = (get().dayNotes[String(dayId)] || []).find(n => n.id === id) ?? ({ id, day_id: Number(dayId), text: '' } as DayNote) + const result = await dayNoteRepo.update(tripId, Number(dayId), before, data) set(state => ({ dayNotes: { ...state.dayNotes, @@ -92,7 +95,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice } })) try { - await dayNotesApi.delete(tripId, dayId, id) + await dayNoteRepo.delete(tripId, Number(dayId), id) } catch (err: unknown) { set({ dayNotes: prev }) throw new Error(getApiErrorMessage(err, 'Error deleting note')) @@ -119,14 +122,15 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice // written: a move is a delete plus a create, so anything omitted here is // silently dropped — which is how a coloured note lost its colour on the // way to another day (#1629). - const result = await dayNotesApi.create(tripId, toDayId, { + // Offline both halves wait in the queue in this order, so the replay keeps it. + const result = await dayNoteRepo.create(tripId, Number(toDayId), { text: note.text, time: note.time, icon: note.icon, color: note.color ?? null, sort_order, }) try { - await dayNotesApi.delete(tripId, fromDayId, noteId) + await dayNoteRepo.delete(tripId, Number(fromDayId), noteId) } catch (delErr: unknown) { // The source survived, so drop the copy rather than leave a duplicate behind. - await dayNotesApi.delete(tripId, toDayId, result.note.id).catch(() => {}) + await dayNoteRepo.delete(tripId, Number(toDayId), result.note.id).catch(() => {}) throw delErr } set(s => ({ diff --git a/client/src/store/slices/daysSlice.ts b/client/src/store/slices/daysSlice.ts index 30b2c0477..b01c6fd34 100644 --- a/client/src/store/slices/daysSlice.ts +++ b/client/src/store/slices/daysSlice.ts @@ -1,4 +1,3 @@ -import { daysApi } from '../../api/client' import { dayRepo } from '../../repo/dayRepo' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' @@ -14,6 +13,7 @@ export interface DaysSlice { insertDay: (tripId: number | string, position?: number) => Promise appendDatedDay: (tripId: number | string) => Promise deleteDay: (tripId: number | string, dayId: number) => Promise + setDayTransportMode: (tripId: number | string, dayId: number, mode: string | null) => Promise } type DayState = Pick @@ -60,7 +60,7 @@ export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({ set({ days: optimistic }) try { - await daysApi.reorder(tripId, orderedIds) + await dayRepo.reorder(tripId, orderedIds) await get().refreshDays(tripId) await get().loadReservations(tripId) } catch (err: unknown) { @@ -73,7 +73,7 @@ export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({ // trip this extends the trip by one day and re-pins dates server-side. insertDay: async (tripId, position) => { try { - const result = await daysApi.create(tripId, { position }) + const result = await dayRepo.insert(tripId, position) await get().refreshDays(tripId) await get().loadReservations(tripId) return result.day @@ -118,4 +118,22 @@ export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({ await get().refreshDays(tripId) await get().loadReservations(tripId) }, + + // The whole-day default route mode. The day shows it at once; a refusal puts + // the previous one back. Offline it waits in the queue. + setDayTransportMode: async (tripId, dayId, mode) => { + const before = get().days.find(d => d.id === dayId)?.default_transport_mode ?? null + const apply = (value: string | null) => set(state => ({ + days: state.days.map(d => (d.id === dayId ? { ...d, default_transport_mode: value } : d)), + })) + apply(mode) + const outcome = await dayRepo.setTransport(tripId, dayId, mode).then( + () => null, + (failure: unknown) => ({ failure }), + ) + if (outcome) { + apply(before) + throw new Error(getApiErrorMessage(outcome.failure, 'Error updating day')) + } + }, }) diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 6052c084b..4baaefd84 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -1,8 +1,8 @@ import { create } from 'zustand' import type { StoreApi } from 'zustand' -import { tripsApi, tagsApi, categoriesApi } from '../api/client' +import { tagsApi, categoriesApi } from '../api/client' import { offlineDb } from '../db/offlineDb' -import { tripRepo } from '../repo/tripRepo' +import { tripRepo, TripNeedsConnectionError } from '../repo/tripRepo' import { dayRepo } from '../repo/dayRepo' import { placeRepo } from '../repo/placeRepo' import { packingRepo } from '../repo/packingRepo' @@ -243,7 +243,7 @@ export const useTripStore = create((set, get) => ({ updateTrip: async (tripId: number | string, data: Partial & { date_shift_mode?: 'keep_bookings' | 'shift_all' }) => { try { const before = get().trip - const result = await tripsApi.update(tripId, data) + const result = await tripRepo.update(tripId, data) set({ trip: result.trip }) // New dates rebuild the day rows, and a stay on a day that went is gone // with it. Stays live in the planner, not here, so every caller of this @@ -266,6 +266,7 @@ export const useTripStore = create((set, get) => ({ await get().loadReservations(tripId) return result.trip } catch (err: unknown) { + if (err instanceof TripNeedsConnectionError) throw err throw new Error(getApiErrorMessage(err, 'Error updating trip')) } }, diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 384e81521..1bb1decbc 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, Reservation, TripFile } from '../types' +import type { Assignment, AssignmentParticipant, BudgetItem, BudgetItemMember, Day, DayNote, Reservation, Trip, TripFile } from '../types' // Map Dexie table names used in `resource` field → actual Dexie tables. function getTable(resource: string): Table | undefined { @@ -189,6 +189,15 @@ async function dropTempRows(mutation: QueuedMutation): Promise { await offlineDb.tours.delete(mutation.tempId) return } + // Visits and day notes live on the cached day, not in a table of their own. + if (mutation.resource === 'assignments' || mutation.resource === 'dayNotes') { + const tempId = mutation.tempId + await offlineDb.days.where('trip_id').equals(mutation.tripId).modify(day => { + if (mutation.resource === 'assignments') day.assignments = (day.assignments ?? []).filter(a => a.id !== tempId) + else day.notes_items = (day.notes_items ?? []).filter(n => n.id !== tempId) + }) + return + } const table = getTable(mutation.resource) if (table) await table.delete(mutation.tempId) } @@ -226,6 +235,10 @@ const TEMP_REF_FIELDS = ['place_id', 'reservation_id', 'accommodation_id'] as co // Objects inside a body that carry such fields of their own: the stay a hotel // booking creates alongside names its place. const TEMP_REF_OBJECTS = ['create_accommodation'] as const +// Resources whose writes carry no version token (see the 409 branch of the flush). +const UNVERSIONED_RESOURCES = new Set(['assignments', 'days', 'dayNotes']) +// A new order of a day's visits can name visits created offline. +const TEMP_REF_LISTS = ['orderedIds'] as const /** * The body with every link to an offline-created entity pointed at the real id @@ -249,9 +262,34 @@ function remapBodyRefs(body: unknown, idMap: Map): unknown { remapped ??= { ...fields } remapped[key] = next } + for (const key of TEMP_REF_LISTS) { + const list = fields[key] + if (!Array.isArray(list) || !list.some(id => typeof id === 'number' && idMap.has(id))) continue + remapped ??= { ...fields } + remapped[key] = list.map(id => (typeof id === 'number' ? idMap.get(id) ?? id : id)) + } return remapped ?? body } +/** + * The body as it is sent: every link the queue has learned the real id of, and + * a list without the temporary ids it has not. Those name a create that did not + * sync, which the server would not know. + */ +function bodyToSend(body: unknown, idMap: Map): unknown { + const remapped = remapBodyRefs(body, idMap) + if (!remapped || typeof remapped !== 'object' || Array.isArray(remapped)) return remapped + const fields = remapped as Record + let trimmed: Record | null = null + for (const key of TEMP_REF_LISTS) { + const list = fields[key] + if (!Array.isArray(list) || !list.some(id => typeof id === 'number' && id < 0)) continue + trimmed ??= { ...fields } + trimmed[key] = list.filter(id => typeof id !== 'number' || id > 0) + } + return trimmed ?? remapped +} + /** * A replayed expense write. Only `item` names the expense: the members route * answers `{ members, item }` with the item's bare row and the paid tick answers @@ -301,6 +339,80 @@ async function reconcileTripFile(data: unknown): Promise { await offlineDb.tripFiles.put({ ...local, ...file.file }) } +/** + * A replayed visit write. A create moves the visit on its cached day from the + * temporary id to the real one; the visit routes answer `{ assignment }`, the + * participants route `{ participants }`, and a reorder or delete names no visit. + */ +async function reconcileAssignment(mutation: QueuedMutation, data: unknown, entityId: number | undefined, idMap: Map): Promise { + const answer = (data && typeof data === 'object' ? data : {}) as { assignment?: unknown; participants?: AssignmentParticipant[] } + if (answer.assignment) { + const saved = assignmentSchema.parse(answer.assignment) as Assignment + const tempId = mutation.tempId + if (tempId !== undefined && tempId !== saved.id) { + await offlineDb.days.where('id').equals(saved.day_id).modify(day => { + day.assignments = (day.assignments ?? []).map(a => (a.id === tempId ? { ...a, ...saved } : a)) + }) + await remapTempId(mutation, saved.id, idMap) + } + await cacheAssignment(saved) + return + } + if (answer.participants && entityId !== undefined) { + const participants = answer.participants + await offlineDb.days.where('trip_id').equals(mutation.tripId).modify(day => { + if (!day.assignments?.some(a => a.id === entityId)) return + day.assignments = day.assignments.map(a => (a.id === entityId ? { ...a, participants } : a)) + }) + } +} + +/** + * A replayed day write. The day answers `{ day }` without its notes, so the + * cached notes stay, and so do the cached visits: they may hold writes still + * waiting behind this one. + */ +async function reconcileDay(data: unknown): Promise { + const day = data && typeof data === 'object' ? (data as { day?: Day }).day : undefined + if (!day || typeof day.id !== 'number') return + const { assignments: _assignments, notes_items: _notes, ...fields } = day + await offlineDb.days.where('id').equals(day.id).modify(row => { Object.assign(row, fields) }) +} + +/** + * A replayed trip write. The answer lays its fields over the cached trip, which + * keeps the counts the list shape carries and the answer does not. + */ +async function reconcileTrip(data: unknown): Promise { + const trip = data && typeof data === 'object' ? (data as { trip?: Trip }).trip : undefined + if (!trip || typeof trip.id !== 'number') return + const local = await offlineDb.trips.get(trip.id) + await offlineDb.trips.put({ ...local, ...trip }) +} + +/** A replayed day note write: the note lands on its cached day, a create under its real id. */ +async function reconcileDayNote(mutation: QueuedMutation, data: unknown, idMap: Map): Promise { + const note = data && typeof data === 'object' ? (data as { note?: DayNote }).note : undefined + if (!note || typeof note.id !== 'number') return + const replaceId = mutation.tempId ?? note.id + await offlineDb.days.where('id').equals(note.day_id).modify(day => { + const notes = day.notes_items ?? [] + day.notes_items = notes.some(n => n.id === replaceId) + ? notes.map(n => (n.id === replaceId ? { ...n, ...note } : n)) + : [...notes.filter(n => n.id !== note.id), note] + }) + if (mutation.tempId !== undefined && mutation.tempId !== note.id) await remapTempId(mutation, note.id, idMap) +} + +/** Visits, days, day notes and the trip itself, each from the answer its route gives. */ +async function reconcilePlanWrite(mutation: QueuedMutation, data: unknown, entityId: number | undefined, idMap: Map): Promise { + if (mutation.method === 'DELETE') return + if (mutation.resource === 'assignments') await reconcileAssignment(mutation, data, entityId, idMap) + else if (mutation.resource === 'days') await reconcileDay(data) + else if (mutation.resource === 'dayNotes') await reconcileDayNote(mutation, data, idMap) + else if (mutation.resource === 'trips') await reconcileTrip(data) +} + /** * A replayed tour write. The answer names the tour by its place, so the * offline place and facet move from the temporary id to the real one. The @@ -523,7 +635,7 @@ export const mutationQueue = { const response = await apiClient.request({ method: mutation.method, url: reqUrl, - data: remapBodyRefs(mutation.body, idMap), + data: bodyToSend(mutation.body, idMap), headers, }) @@ -532,9 +644,9 @@ export const mutationQueue = { await offlineDb.roadtripPreferences.put(saved) } // Apply canonical server response to Dexie - if (mutation.resource === 'assignments') { - await cacheAssignment(assignmentSchema.parse(response.data.assignment)) - } + // The plan's own rows have no table the generic branches below would + // write, so those pass over them. + await reconcilePlanWrite(mutation, response.data, reqEntityId, idMap) if (mutation.resource === 'tours') { await reconcileTour(mutation, tourCreateResponseSchema.parse(response.data), idMap) } else if (mutation.resource === 'budgetItems' && mutation.method !== 'DELETE') { @@ -609,7 +721,10 @@ export const mutationQueue = { // the user's strategy instead of failing it. Deliberately scoped to // edits: an offline DELETE is "delete wins" by design (no CAS on the // delete path), so it never reaches here. See the wiki Offline doc. - if (httpStatus === 409 && mutation.method !== 'DELETE') { + // Visits, days and day notes carry no version, so a 409 there is a + // refusal (a Tour already on that day) that no retry can change, and + // it fails like any other 4xx. + if (httpStatus === 409 && mutation.method !== 'DELETE' && !UNVERSIONED_RESOURCES.has(mutation.resource ?? '')) { const server = extractConflictServer(err) const strategy = getOfflinePrefs().conflictStrategy if (strategy === 'server') { diff --git a/client/src/utils/needsConnection.test.ts b/client/src/utils/needsConnection.test.ts new file mode 100644 index 000000000..b2d4e881e --- /dev/null +++ b/client/src/utils/needsConnection.test.ts @@ -0,0 +1,29 @@ +// FE-UTIL-NEEDSCONN-001 to FE-UTIL-NEEDSCONN-002 +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { offlineOr, refusedOffline } from './needsConnection'; + +const t = (key: string) => `t:${key}`; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +afterEach(() => setOnline(true)); + +describe('needsConnection', () => { + it('FE-UTIL-NEEDSCONN-001: offline a write the queue cannot hold is refused with the hint', () => { + const toast = { error: vi.fn() }; + expect(refusedOffline(toast, t)).toBe(false); + expect(toast.error).not.toHaveBeenCalled(); + + setOnline(false); + expect(refusedOffline(toast, t)).toBe(true); + expect(toast.error).toHaveBeenCalledWith('t:common.needsConnection'); + }); + + it('FE-UTIL-NEEDSCONN-002: a failed write reads as needing a connection only while offline', () => { + expect(offlineOr(t, 'Error')).toBe('Error'); + setOnline(false); + expect(offlineOr(t, 'Error')).toBe('t:common.needsConnection'); + }); +}); diff --git a/client/src/utils/needsConnection.ts b/client/src/utils/needsConnection.ts new file mode 100644 index 000000000..7a15ffcf2 --- /dev/null +++ b/client/src/utils/needsConnection.ts @@ -0,0 +1,23 @@ +import { isEffectivelyOffline } from '../sync/networkMode'; + +/** + * For a write the offline queue cannot hold: data that is not kept on the + * device (Collab, packing bags, road trip stops) or a change only the server + * can make (an upload, an import, a member looked up). Offline it says so in + * the user's language and answers true, so the caller stops before the request + * fails with a bare network error. + */ +export function refusedOffline(toast: { error: (message: string) => unknown }, t: (key: string) => string): boolean { + if (!isEffectivelyOffline()) return false; + toast.error(t('common.needsConnection')); + return true; +} + +/** + * The message for a write that failed: offline the hint that it needs a + * connection, otherwise the caller's own. For the writes whose failure handling + * (a modal that stays open, a list read again) should run as before. + */ +export function offlineOr(t: (key: string) => string, message: string): string { + return isEffectivelyOffline() ? t('common.needsConnection') : message; +} diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts index d6c654790..96b3b91d4 100644 --- a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts +++ b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts @@ -678,7 +678,7 @@ describe('useMPlanTimeline', () => { const { result } = await renderTimeline(makePlanner()) await act(async () => { result.current.setLegMode(11, 'walking') }) expect(useTripStore.getState().assignments['2'].map(a => a.leg_transport_mode)).toEqual(['walking', undefined]) - expect(assignmentsApi.updateTransport).toHaveBeenCalledWith(1, 11, 'walking') + await waitFor(() => expect(assignmentsApi.updateTransport).toHaveBeenCalledWith(1, 11, 'walking')) }) it('FE-MOB-PLTL-042: reverts to the server state when the leg mode fails to save', async () => { diff --git a/client/tests/unit/slices/assignmentsSlice.test.ts b/client/tests/unit/slices/assignmentsSlice.test.ts index 3c7ca6720..fb86e3348 100644 --- a/client/tests/unit/slices/assignmentsSlice.test.ts +++ b/client/tests/unit/slices/assignmentsSlice.test.ts @@ -1,9 +1,10 @@ -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 { buildPlace, buildAssignment } from '../../helpers/factories'; import { server } from '../../helpers/msw/server'; +import { clearAll, offlineDb } from '../../../src/db/offlineDb'; vi.mock('../../../src/api/websocket', () => ({ connect: vi.fn(), @@ -567,4 +568,63 @@ describe('assignmentsSlice', () => { expect(useTripStore.getState().assignments).toEqual(next); }); }); + + // #2601: offline a place goes on its day, comes off it, moves and is reordered + // through the queue instead of failing. + describe('offline', () => { + const setOnline = (v: boolean) => Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); + beforeEach(async () => { await clearAll(); setOnline(false); }); + afterEach(async () => { setOnline(true); await clearAll(); }); + + async function queued(): Promise<[string, string][]> { + return (await offlineDb.mutationQueue.toArray()) + .sort((a, b) => a.createdAt - b.createdAt) + .map(m => [m.method, m.url]); + } + + it('FE-ASSIGN-OFF-001: a place put on a day offline stays there and waits in the queue', async () => { + const place = buildPlace({ id: 10, trip_id: 1 }); + seedStore(useTripStore, { places: [place], assignments: { '1': [] } }); + + const visit = await useTripStore.getState().assignPlaceToDay(1, 1, 10); + + expect(visit?.id).toBeLessThan(0); + expect(useTripStore.getState().assignments['1'].map(a => a.place_id)).toEqual([10]); + expect(await queued()).toEqual([['POST', '/trips/1/days/1/assignments']]); + }); + + it('FE-ASSIGN-OFF-002: removing, reordering and moving offline keep the change and queue it', async () => { + const a = buildAssignment({ id: 5, day_id: 1, order_index: 0 }); + const b = buildAssignment({ id: 6, day_id: 1, order_index: 1 }); + seedStore(useTripStore, { assignments: { '1': [a, b], '2': [] } }); + + await useTripStore.getState().reorderAssignments(1, 1, [6, 5]); + await useTripStore.getState().moveAssignment(1, 6, 1, 2); + await useTripStore.getState().removeAssignment(1, 1, 5); + + expect(useTripStore.getState().assignments['1']).toEqual([]); + expect(useTripStore.getState().assignments['2'].map(x => x.id)).toEqual([6]); + expect(await queued()).toEqual([ + ['PUT', '/trips/1/days/1/assignments/reorder'], + ['PUT', '/trips/1/assignments/6/move'], + ['DELETE', '/trips/1/days/1/assignments/5'], + ]); + }); + + it('FE-ASSIGN-OFF-003: a leg mode and the travellers chosen offline land on the visit', async () => { + seedStore(useTripStore, { assignments: { '2': [buildAssignment({ id: 5, day_id: 2 })] } }); + await offlineDb.tripMembers.put({ tripId: 1, id: 4, username: 'Lena', avatar: null }); + + await useTripStore.getState().setAssignmentParticipants(1, 2, 5, [4]); + await useTripStore.getState().setAssignmentLegMode(1, 2, 5, 'driving'); + + const visit = useTripStore.getState().assignments['2'][0]; + expect(visit.participants).toEqual([{ user_id: 4, username: 'Lena', avatar: null }]); + expect(visit.leg_transport_mode).toBe('driving'); + expect(await queued()).toEqual([ + ['PUT', '/trips/1/assignments/5/participants'], + ['PUT', '/trips/1/assignments/5/transport'], + ]); + }); + }); }); diff --git a/client/tests/unit/slices/dayNotesSlice.test.ts b/client/tests/unit/slices/dayNotesSlice.test.ts index 65d488a59..03834308e 100644 --- a/client/tests/unit/slices/dayNotesSlice.test.ts +++ b/client/tests/unit/slices/dayNotesSlice.test.ts @@ -1,9 +1,10 @@ -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 { buildDay, buildDayNote } from '../../helpers/factories'; import { server } from '../../helpers/msw/server'; +import { clearAll, offlineDb } from '../../../src/db/offlineDb'; vi.mock('../../../src/api/websocket', () => ({ connect: vi.fn(), @@ -206,4 +207,49 @@ describe('dayNotesSlice', () => { expect(updatedDay?.title).toBe('Day at the Beach'); }); }); + + // #2601: offline the plan writes wait in the queue instead of failing. + describe('offline', () => { + const setOnline = (v: boolean) => Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); + afterEach(() => setOnline(true)); + + it('FE-DAYNOTES-OFF-001: a note added offline stays on the day and waits in the queue', async () => { + await clearAll(); + seedStore(useTripStore, { dayNotes: { '1': [] } }); + setOnline(false); + + const note = await useTripStore.getState().addDayNote(1, 1, { text: 'Lunch' }); + + expect(note.id).toBeLessThan(0); + expect(useTripStore.getState().dayNotes['1'].map(n => n.text)).toEqual(['Lunch']); + expect((await offlineDb.mutationQueue.toArray()).map(m => m.url)).toEqual(['/trips/1/days/1/notes']); + }); + + it('FE-DAYNOTES-OFF-002: a day renamed offline keeps the new name and waits in the queue', async () => { + await clearAll(); + seedStore(useTripStore, { days: [buildDay({ id: 1, trip_id: 1, title: null })] }); + setOnline(false); + + await useTripStore.getState().updateDayTitle(1, 1, 'Arrival'); + + expect(useTripStore.getState().days[0].title).toBe('Arrival'); + expect((await offlineDb.mutationQueue.toArray()).map(m => m.body)).toEqual([{ title: 'Arrival' }]); + }); + + it('FE-DAYNOTES-OFF-003: a note moved offline is created on the other day and deleted on its own, in that order', async () => { + await clearAll(); + seedStore(useTripStore, { dayNotes: { '1': [buildDayNote({ id: 900, day_id: 1, text: 'Museum' })], '2': [] } }); + setOnline(false); + + await useTripStore.getState().moveDayNote(1, 1, 2, 900); + + expect(useTripStore.getState().dayNotes['2'].map(n => n.text)).toEqual(['Museum']); + expect(useTripStore.getState().dayNotes['1']).toEqual([]); + const queued = (await offlineDb.mutationQueue.toArray()).sort((a, b) => a.createdAt - b.createdAt); + expect(queued.map(m => [m.method, m.url])).toEqual([ + ['POST', '/trips/1/days/2/notes'], + ['DELETE', '/trips/1/days/1/notes/900'], + ]); + }); + }); }); diff --git a/client/tests/unit/sync/mutationQueue.days.test.ts b/client/tests/unit/sync/mutationQueue.days.test.ts new file mode 100644 index 000000000..647eb7000 --- /dev/null +++ b/client/tests/unit/sync/mutationQueue.days.test.ts @@ -0,0 +1,191 @@ +/** + * mutationQueue replay of offline plan writes (#2601): a visit put on a day + * moves to its real id and the new order of the day follows it, a note does the + * same on its day, a day answer keeps the notes the cache holds, a trip answer + * keeps the counts of the list, and a visit the server refuses with 409 fails + * instead of waiting as a conflict. + */ +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 { assignmentRepo } from '../../../src/repo/assignmentRepo'; +import { dayNoteRepo } from '../../../src/repo/dayNoteRepo'; +import { dayRepo } from '../../../src/repo/dayRepo'; +import { tripRepo } from '../../../src/repo/tripRepo'; +import { setAuthed } from '../../../src/sync/authGate'; +import { mutationQueue } from '../../../src/sync/mutationQueue'; +import { buildAssignment, buildDay, buildDayNote, buildPlace, buildTrip } from '../../helpers/factories'; +import { server } from '../../helpers/msw/server'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +const place = buildPlace({ id: 300, trip_id: 4 }); + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setAuthed(true); + setOnline(false); + await offlineDb.days.put( + buildDay({ + id: 70, + trip_id: 4, + title: 'Old', + assignments: [ + buildAssignment({ id: 500, day_id: 70, order_index: 0, place: buildPlace({ id: 301, trip_id: 4 }) }), + ], + notes_items: [buildDayNote({ id: 900, day_id: 70 })], + }) + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('mutationQueue.flush, plan', () => { + it('FE-SYNC-DAYS-001: a replayed visit takes its real id and the queued order names it', async () => { + const visit = await assignmentRepo.create(4, 70, place); + await assignmentRepo.reorder(4, 70, [visit.id, 500]); + await assignmentRepo.setLegMode(4, visit.id, 'driving'); + const orders: unknown[] = []; + const legs: string[] = []; + server.use( + http.post('/api/trips/4/days/70/assignments', () => + HttpResponse.json({ assignment: buildAssignment({ id: 520, day_id: 70, order_index: 1, place }) }) + ), + http.put('/api/trips/4/days/70/assignments/reorder', async ({ request }) => { + orders.push(await request.json()); + return HttpResponse.json({ success: true }); + }), + http.put('/api/trips/4/assignments/:id/transport', ({ params }) => { + legs.push(String(params.id)); + return HttpResponse.json({ + assignment: buildAssignment({ id: 520, day_id: 70, order_index: 0, leg_transport_mode: 'driving', place }), + }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(orders).toEqual([{ orderedIds: [520, 500] }]); + expect(legs).toEqual(['520']); + expect(await offlineDb.mutationQueue.count()).toBe(0); + const ids = (await offlineDb.days.get(70))?.assignments?.map((a) => a.id); + expect(ids).toContain(520); + expect(ids).not.toContain(visit.id); + }); + + it('FE-SYNC-DAYS-002: an order that names a visit whose create failed is sent without it', async () => { + const visit = await assignmentRepo.create(4, 70, place); + await assignmentRepo.reorder(4, 70, [visit.id, 500]); + const orders: unknown[] = []; + server.use( + http.post('/api/trips/4/days/70/assignments', () => + HttpResponse.json({ error: 'Place not found' }, { status: 404 }) + ), + http.put('/api/trips/4/days/70/assignments/reorder', async ({ request }) => { + orders.push(await request.json()); + return HttpResponse.json({ success: true }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(orders).toEqual([{ orderedIds: [500] }]); + expect((await offlineDb.days.get(70))?.assignments?.map((a) => a.id)).toEqual([500]); + }); + + it('FE-SYNC-DAYS-003: a visit refused with 409 fails, it does not wait as a conflict', async () => { + await assignmentRepo.create(4, 70, place); + server.use( + http.post('/api/trips/4/days/70/assignments', () => + HttpResponse.json({ error: 'Tour is already assigned to this day' }, { status: 409 }) + ) + ); + setOnline(true); + + await mutationQueue.flush(); + + const [row] = await offlineDb.mutationQueue.toArray(); + expect(row.status).toBe('failed'); + expect((await offlineDb.days.get(70))?.assignments?.map((a) => a.id)).toEqual([500]); + }); + + it('FE-SYNC-DAYS-004: the participants answer lands on the visit it names', async () => { + await assignmentRepo.setParticipants(4, 500, [5]); + server.use( + http.put('/api/trips/4/assignments/500/participants', () => + HttpResponse.json({ participants: [{ user_id: 5, username: 'tom', avatar: null }] }) + ) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect((await offlineDb.days.get(70))?.assignments?.[0].participants).toEqual([ + { user_id: 5, username: 'tom', avatar: null }, + ]); + }); + + it('FE-SYNC-DAYS-005: a replayed note takes its real id and the queued edit follows it', async () => { + const { note } = await dayNoteRepo.create(4, 70, { text: 'Lunch' }); + await dayNoteRepo.update(4, 70, note, { text: 'Brunch' }); + const puts: string[] = []; + server.use( + http.post('/api/trips/4/days/70/notes', () => + HttpResponse.json({ note: buildDayNote({ id: 901, day_id: 70, text: 'Lunch' }) }) + ), + http.put('/api/trips/4/days/70/notes/:id', ({ params }) => { + puts.push(String(params.id)); + return HttpResponse.json({ note: buildDayNote({ id: 901, day_id: 70, text: 'Brunch' }) }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(puts).toEqual(['901']); + expect((await offlineDb.days.get(70))?.notes_items?.map((n) => [n.id, n.text])).toEqual([ + [900, 'Test note'], + [901, 'Brunch'], + ]); + }); + + it('FE-SYNC-DAYS-006: the day answer keeps the notes and visits the cache holds', async () => { + await dayRepo.update(4, 70, { title: 'Arrival' }); + server.use( + http.put('/api/trips/4/days/70', () => + HttpResponse.json({ day: { id: 70, trip_id: 4, title: 'Arrival', notes: null, assignments: [] } }) + ) + ); + setOnline(true); + + await mutationQueue.flush(); + + const day = await offlineDb.days.get(70); + expect(day?.title).toBe('Arrival'); + expect(day?.notes_items?.map((n) => n.id)).toEqual([900]); + expect(day?.assignments?.map((a) => a.id)).toEqual([500]); + }); + + it('FE-SYNC-DAYS-007: a trip renamed offline syncs and keeps the counts of the cached row', async () => { + await offlineDb.trips.put({ ...buildTrip({ id: 4, title: 'Rome' }), place_count: 7 } as never); + await tripRepo.update(4, { title: 'Roma', start_date: buildTrip({ id: 4 }).start_date }); + expect((await offlineDb.trips.get(4))?.title).toBe('Roma'); + server.use(http.put('/api/trips/4', () => HttpResponse.json({ trip: { ...buildTrip({ id: 4 }), title: 'Roma' } }))); + setOnline(true); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await offlineDb.trips.get(4)).toMatchObject({ title: 'Roma', place_count: 7 }); + }); +}); diff --git a/shared/src/i18n/ar/common.ts b/shared/src/i18n/ar/common.ts index c269a0d89..eb7d50f72 100644 --- a/shared/src/i18n/ar/common.ts +++ b/shared/src/i18n/ar/common.ts @@ -87,5 +87,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'يتوفر إصدار جديد', 'common.errorUpdateBody': 'تم تحديث TREK أثناء فتح علامة التبويب هذه. أعد التحميل للحصول على الإصدار الجديد.', 'common.errorPluginTitle': 'تعذّر عرض هذه الإضافة', + 'common.needsConnection': 'يتطلب هذا اتصالاً. حاول مرة أخرى عندما يعود TREK للاتصال.', }; export default common; diff --git a/shared/src/i18n/ar/dashboard.ts b/shared/src/i18n/ar/dashboard.ts index 05dd4bfa7..268bc35d7 100644 --- a/shared/src/i18n/ar/dashboard.ts +++ b/shared/src/i18n/ar/dashboard.ts @@ -291,5 +291,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'إضافة منطقة زمنية', 'dashboard.aria.removeTimezone': 'إزالة {city}', 'dashboard.dayCountRequired': 'عدد الأيام مطلوب', + 'dashboard.tripChangeNeedsConnection': + 'تغيير التواريخ أو عدد الأيام أو العملة أو صورة الغلاف يتطلب اتصالاً. يمكن تغيير الاسم والوصف والتذكير دون اتصال.', }; export default dashboard; diff --git a/shared/src/i18n/az/common.ts b/shared/src/i18n/az/common.ts index bc21bf835..d7885f85d 100644 --- a/shared/src/i18n/az/common.ts +++ b/shared/src/i18n/az/common.ts @@ -79,5 +79,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'Yeni versiya mövcuddur', 'common.errorUpdateBody': 'Bu səhifə açıq olarkən TREK yeniləndi. Yeni versiyanı əldə etmək üçün səhifəni yenilə.', 'common.errorPluginTitle': 'Bu plagini göstərmək mümkün olmadı', + 'common.needsConnection': 'Bunun üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.', }; export default common; diff --git a/shared/src/i18n/az/dashboard.ts b/shared/src/i18n/az/dashboard.ts index 810e3e379..c166180c7 100644 --- a/shared/src/i18n/az/dashboard.ts +++ b/shared/src/i18n/az/dashboard.ts @@ -223,5 +223,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Saat qurşağı əlavə et', 'dashboard.aria.removeTimezone': '{city} saat qurşağını sil', 'dashboard.dayCountRequired': 'Günlərin sayı tələb olunur', + 'dashboard.tripChangeNeedsConnection': + 'Tarixləri, günlərin sayını, valyutanı və ya üz şəklini dəyişmək üçün bağlantı lazımdır. Adı, təsviri və xatırlatmanı oflayn da dəyişə bilərsiniz.', }; export default dashboard; diff --git a/shared/src/i18n/br/common.ts b/shared/src/i18n/br/common.ts index 7a9106d34..fd891d661 100644 --- a/shared/src/i18n/br/common.ts +++ b/shared/src/i18n/br/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'O TREK foi atualizado enquanto esta aba estava aberta. Recarregue para obter a nova versão.', 'common.errorPluginTitle': 'Não foi possível exibir este plugin', + 'common.needsConnection': 'Isso precisa de conexão. Tente de novo quando o TREK voltar a ficar online.', }; export default common; diff --git a/shared/src/i18n/br/dashboard.ts b/shared/src/i18n/br/dashboard.ts index b078bdfa2..aa623cb3c 100644 --- a/shared/src/i18n/br/dashboard.ts +++ b/shared/src/i18n/br/dashboard.ts @@ -220,5 +220,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Adicionar fuso horário', 'dashboard.aria.removeTimezone': 'Remover {city}', 'dashboard.dayCountRequired': 'O número de dias é obrigatório', + 'dashboard.tripChangeNeedsConnection': + 'Mudar as datas, o número de dias, a moeda ou a capa precisa de conexão. Nome, descrição e lembrete podem ser alterados offline.', }; export default dashboard; diff --git a/shared/src/i18n/ca/common.ts b/shared/src/i18n/ca/common.ts index 1ec351602..3d7590b2d 100644 --- a/shared/src/i18n/ca/common.ts +++ b/shared/src/i18n/ca/common.ts @@ -81,5 +81,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': "TREK s'ha actualitzat mentre aquesta pestanya era oberta. Torna a carregar per obtenir la versió nova.", 'common.errorPluginTitle': "No s'ha pogut mostrar aquest connector", + 'common.needsConnection': 'Això necessita connexió. Torna-ho a provar quan TREK torni a estar en línia.', }; export default common; diff --git a/shared/src/i18n/ca/dashboard.ts b/shared/src/i18n/ca/dashboard.ts index 1183c26a3..230da1471 100644 --- a/shared/src/i18n/ca/dashboard.ts +++ b/shared/src/i18n/ca/dashboard.ts @@ -223,5 +223,7 @@ const dashboard: TranslationStrings = { 'dashboard.mobile.spotlightDayOf': 'Dia {day} de {total}', 'dashboard.mobile.spotlightDays': '{count} dies', 'dashboard.mobile.spotlightDays.one': '{count} dia', + 'dashboard.tripChangeNeedsConnection': + 'Canviar les dates, el nombre de dies, la moneda o la portada necessita connexió. El nom, la descripció i el recordatori es poden canviar sense connexió.', }; export default dashboard; diff --git a/shared/src/i18n/cs/common.ts b/shared/src/i18n/cs/common.ts index 3dc241e83..bcd36e05b 100644 --- a/shared/src/i18n/cs/common.ts +++ b/shared/src/i18n/cs/common.ts @@ -81,5 +81,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'Je dostupná nová verze', 'common.errorUpdateBody': 'TREK byl aktualizován, zatímco byla tato karta otevřená. Načtěte znovu pro novou verzi.', 'common.errorPluginTitle': 'Tento plugin se nepodařilo zobrazit', + 'common.needsConnection': 'Na tohle je potřeba připojení. Zkus to znovu, až bude TREK zase online.', }; export default common; diff --git a/shared/src/i18n/cs/dashboard.ts b/shared/src/i18n/cs/dashboard.ts index 3a0c562dd..83e076dd5 100644 --- a/shared/src/i18n/cs/dashboard.ts +++ b/shared/src/i18n/cs/dashboard.ts @@ -238,5 +238,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Přidat časové pásmo', 'dashboard.aria.removeTimezone': 'Odebrat {city}', 'dashboard.dayCountRequired': 'Počet dní je povinný', + 'dashboard.tripChangeNeedsConnection': + 'Změna termínu, počtu dní, měny nebo titulního obrázku potřebuje připojení. Název, popis a připomínku můžeš změnit i offline.', }; export default dashboard; diff --git a/shared/src/i18n/de/common.ts b/shared/src/i18n/de/common.ts index 06849b8dc..c179d89c7 100644 --- a/shared/src/i18n/de/common.ts +++ b/shared/src/i18n/de/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREK wurde aktualisiert, während dieser Tab offen war. Lade neu, um die neue Version zu erhalten.', 'common.errorPluginTitle': 'Dieses Plugin konnte nicht angezeigt werden', + 'common.needsConnection': 'Dafür braucht es eine Verbindung. Versuch es noch einmal, sobald TREK wieder online ist.', }; export default common; diff --git a/shared/src/i18n/de/dashboard.ts b/shared/src/i18n/de/dashboard.ts index 89016e0b8..39be8a5fa 100644 --- a/shared/src/i18n/de/dashboard.ts +++ b/shared/src/i18n/de/dashboard.ts @@ -224,5 +224,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Zeitzone hinzufügen', 'dashboard.aria.removeTimezone': '{city} entfernen', 'dashboard.dayCountRequired': 'Anzahl der Tage ist erforderlich', + 'dashboard.tripChangeNeedsConnection': + 'Neue Daten, eine andere Anzahl Tage, eine andere Währung oder ein neues Titelbild brauchen eine Verbindung. Name, Beschreibung und Erinnerung lassen sich auch offline ändern.', }; export default dashboard; diff --git a/shared/src/i18n/en/common.ts b/shared/src/i18n/en/common.ts index 00b0e8f8d..6ad3fbd85 100644 --- a/shared/src/i18n/en/common.ts +++ b/shared/src/i18n/en/common.ts @@ -79,5 +79,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'A new version is available', 'common.errorUpdateBody': 'TREK was updated while this tab was open. Reload to get the new version.', 'common.errorPluginTitle': 'This plugin could not be shown', + 'common.needsConnection': 'This needs a connection. Try again once TREK is back online.', }; export default common; diff --git a/shared/src/i18n/en/dashboard.ts b/shared/src/i18n/en/dashboard.ts index cdfbdd67a..8d704a6d0 100644 --- a/shared/src/i18n/en/dashboard.ts +++ b/shared/src/i18n/en/dashboard.ts @@ -221,5 +221,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Add timezone', 'dashboard.aria.removeTimezone': 'Remove {city}', 'dashboard.dayCountRequired': 'Number of days is required', + 'dashboard.tripChangeNeedsConnection': + 'New dates, a different number of days, another currency or a new cover need a connection. The name, description and reminder can be changed offline.', }; export default dashboard; diff --git a/shared/src/i18n/es/common.ts b/shared/src/i18n/es/common.ts index 028d58bcd..eb7c63af8 100644 --- a/shared/src/i18n/es/common.ts +++ b/shared/src/i18n/es/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREK se actualizó mientras esta pestaña estaba abierta. Recarga para obtener la nueva versión.', 'common.errorPluginTitle': 'No se pudo mostrar este plugin', + 'common.needsConnection': 'Esto necesita conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.', }; export default common; diff --git a/shared/src/i18n/es/dashboard.ts b/shared/src/i18n/es/dashboard.ts index 00ba21568..03bddcab2 100644 --- a/shared/src/i18n/es/dashboard.ts +++ b/shared/src/i18n/es/dashboard.ts @@ -222,5 +222,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Añadir zona horaria', 'dashboard.aria.removeTimezone': 'Eliminar {city}', 'dashboard.dayCountRequired': 'El número de días es obligatorio', + 'dashboard.tripChangeNeedsConnection': + 'Cambiar las fechas, el número de días, la moneda o la portada necesita conexión. El nombre, la descripción y el recordatorio se pueden cambiar sin conexión.', }; export default dashboard; diff --git a/shared/src/i18n/et/common.ts b/shared/src/i18n/et/common.ts index 11dd70051..3acd1c202 100644 --- a/shared/src/i18n/et/common.ts +++ b/shared/src/i18n/et/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREKi uuendati ajal, kui see vahekaart oli avatud. Uue versiooni kasutamiseks laadi leht uuesti.', 'common.errorPluginTitle': 'Seda pluginat ei saanud kuvada', + 'common.needsConnection': 'Selleks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.', }; export default common; diff --git a/shared/src/i18n/et/dashboard.ts b/shared/src/i18n/et/dashboard.ts index 5c9f4f9a0..610733276 100644 --- a/shared/src/i18n/et/dashboard.ts +++ b/shared/src/i18n/et/dashboard.ts @@ -220,5 +220,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Lisa ajavöönd', 'dashboard.aria.removeTimezone': 'Eemalda {city}', 'dashboard.dayCountRequired': 'Päevade arv on kohustuslik', + 'dashboard.tripChangeNeedsConnection': + 'Kuupäevade, päevade arvu, valuuta või kaanepildi muutmiseks on vaja ühendust. Nime, kirjeldust ja meeldetuletust saab muuta ka võrguühenduseta.', }; export default dashboard; diff --git a/shared/src/i18n/fr/common.ts b/shared/src/i18n/fr/common.ts index af4427af5..a98804753 100644 --- a/shared/src/i18n/fr/common.ts +++ b/shared/src/i18n/fr/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREK a été mis à jour pendant que cet onglet était ouvert. Rechargez pour obtenir la nouvelle version.', 'common.errorPluginTitle': "Ce plugin n'a pas pu être affiché", + 'common.needsConnection': 'Cela nécessite une connexion. Réessaie dès que TREK sera de nouveau en ligne.', }; export default common; diff --git a/shared/src/i18n/fr/dashboard.ts b/shared/src/i18n/fr/dashboard.ts index ef282fc3d..7eed7552a 100644 --- a/shared/src/i18n/fr/dashboard.ts +++ b/shared/src/i18n/fr/dashboard.ts @@ -224,5 +224,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Ajouter un fuseau horaire', 'dashboard.aria.removeTimezone': 'Supprimer {city}', 'dashboard.dayCountRequired': 'Le nombre de jours est requis', + 'dashboard.tripChangeNeedsConnection': + 'Changer les dates, le nombre de jours, la devise ou la couverture nécessite une connexion. Le nom, la description et le rappel peuvent être modifiés hors ligne.', }; export default dashboard; diff --git a/shared/src/i18n/gr/common.ts b/shared/src/i18n/gr/common.ts index 9993a304d..902c4e07a 100644 --- a/shared/src/i18n/gr/common.ts +++ b/shared/src/i18n/gr/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'Το TREK ενημερώθηκε ενώ αυτή η καρτέλα ήταν ανοιχτή. Κάντε επαναφόρτωση για τη νέα έκδοση.', 'common.errorPluginTitle': 'Δεν ήταν δυνατή η εμφάνιση αυτού του πρόσθετου', + 'common.needsConnection': 'Αυτό χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.', }; export default common; diff --git a/shared/src/i18n/gr/dashboard.ts b/shared/src/i18n/gr/dashboard.ts index 761286762..59ce50608 100644 --- a/shared/src/i18n/gr/dashboard.ts +++ b/shared/src/i18n/gr/dashboard.ts @@ -225,5 +225,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Add timezone', // en-fallback 'dashboard.aria.removeTimezone': 'Remove {city}', // en-fallback 'dashboard.dayCountRequired': 'Ο αριθμός ημερών είναι υποχρεωτικός', + 'dashboard.tripChangeNeedsConnection': + 'Η αλλαγή ημερομηνιών, αριθμού ημερών, νομίσματος ή εξωφύλλου χρειάζεται σύνδεση. Το όνομα, η περιγραφή και η υπενθύμιση αλλάζουν και εκτός σύνδεσης.', }; export default dashboard; diff --git a/shared/src/i18n/hu/common.ts b/shared/src/i18n/hu/common.ts index 46fb36b69..3d881493b 100644 --- a/shared/src/i18n/hu/common.ts +++ b/shared/src/i18n/hu/common.ts @@ -79,5 +79,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'Új verzió érhető el', 'common.errorUpdateBody': 'A TREK frissült, amíg ez a lap nyitva volt. Töltse újra az új verzióért.', 'common.errorPluginTitle': 'Ezt a bővítményt nem sikerült megjeleníteni', + 'common.needsConnection': 'Ehhez kapcsolat kell. Próbáld újra, amint a TREK újra online lesz.', }; export default common; diff --git a/shared/src/i18n/hu/dashboard.ts b/shared/src/i18n/hu/dashboard.ts index 52b8ae1ab..f1b9be586 100644 --- a/shared/src/i18n/hu/dashboard.ts +++ b/shared/src/i18n/hu/dashboard.ts @@ -224,5 +224,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Időzóna hozzáadása', 'dashboard.aria.removeTimezone': '{city} eltávolítása', 'dashboard.dayCountRequired': 'A napok száma kötelező', + 'dashboard.tripChangeNeedsConnection': + 'Az időpontok, a napok száma, a pénznem vagy a borítókép módosításához kapcsolat kell. A név, a leírás és az emlékeztető offline is módosítható.', }; export default dashboard; diff --git a/shared/src/i18n/id/common.ts b/shared/src/i18n/id/common.ts index 5cf8e179c..1a132729b 100644 --- a/shared/src/i18n/id/common.ts +++ b/shared/src/i18n/id/common.ts @@ -77,5 +77,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'Versi baru tersedia', 'common.errorUpdateBody': 'TREK diperbarui saat tab ini terbuka. Muat ulang untuk mendapatkan versi baru.', 'common.errorPluginTitle': 'Plugin ini tidak dapat ditampilkan', + 'common.needsConnection': 'Ini memerlukan koneksi. Coba lagi begitu TREK kembali online.', }; export default common; diff --git a/shared/src/i18n/id/dashboard.ts b/shared/src/i18n/id/dashboard.ts index b12dcdcc3..1e96aed12 100644 --- a/shared/src/i18n/id/dashboard.ts +++ b/shared/src/i18n/id/dashboard.ts @@ -204,5 +204,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Tambah zona waktu', 'dashboard.aria.removeTimezone': 'Hapus {city}', 'dashboard.dayCountRequired': 'Jumlah hari wajib diisi', + 'dashboard.tripChangeNeedsConnection': + 'Mengubah tanggal, jumlah hari, mata uang, atau sampul memerlukan koneksi. Nama, deskripsi, dan pengingat bisa diubah secara offline.', }; export default dashboard; diff --git a/shared/src/i18n/it/common.ts b/shared/src/i18n/it/common.ts index 8e1e59bdb..290935cae 100644 --- a/shared/src/i18n/it/common.ts +++ b/shared/src/i18n/it/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREK è stato aggiornato mentre questa scheda era aperta. Ricarica per ottenere la nuova versione.', 'common.errorPluginTitle': 'Impossibile mostrare questo plugin', + 'common.needsConnection': 'Serve una connessione. Riprova quando TREK tornerà online.', }; export default common; diff --git a/shared/src/i18n/it/dashboard.ts b/shared/src/i18n/it/dashboard.ts index fb6b9fb24..8bd8fd8e3 100644 --- a/shared/src/i18n/it/dashboard.ts +++ b/shared/src/i18n/it/dashboard.ts @@ -224,5 +224,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Aggiungi fuso orario', 'dashboard.aria.removeTimezone': 'Rimuovi {city}', 'dashboard.dayCountRequired': 'Il numero di giorni è obbligatorio', + 'dashboard.tripChangeNeedsConnection': + 'Cambiare le date, il numero di giorni, la valuta o la copertina richiede una connessione. Nome, descrizione e promemoria si possono cambiare anche offline.', }; export default dashboard; diff --git a/shared/src/i18n/ja/common.ts b/shared/src/i18n/ja/common.ts index c3252d7fb..6aae55ddf 100644 --- a/shared/src/i18n/ja/common.ts +++ b/shared/src/i18n/ja/common.ts @@ -78,5 +78,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'このタブを開いている間に TREK が更新されました。再読み込みして新しいバージョンを取得してください。', 'common.errorPluginTitle': 'このプラグインを表示できませんでした', + 'common.needsConnection': 'この操作には接続が必要です。TREK がオンラインに戻ってから、もう一度お試しください。', }; export default common; diff --git a/shared/src/i18n/ja/dashboard.ts b/shared/src/i18n/ja/dashboard.ts index d28a10991..3b608e45f 100644 --- a/shared/src/i18n/ja/dashboard.ts +++ b/shared/src/i18n/ja/dashboard.ts @@ -202,5 +202,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'タイムゾーンを追加', 'dashboard.aria.removeTimezone': '{city}を削除', 'dashboard.dayCountRequired': '日数は必須です', + 'dashboard.tripChangeNeedsConnection': + '日程、日数、通貨、カバー画像の変更には接続が必要です。名前、説明、リマインダーはオフラインでも変更できます。', }; export default dashboard; diff --git a/shared/src/i18n/ko/common.ts b/shared/src/i18n/ko/common.ts index 0cebdb73f..07cb299f5 100644 --- a/shared/src/i18n/ko/common.ts +++ b/shared/src/i18n/ko/common.ts @@ -77,5 +77,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': '새 버전이 있습니다', 'common.errorUpdateBody': '이 탭이 열려 있는 동안 TREK이 업데이트되었습니다. 새로고침하여 새 버전을 받으세요.', 'common.errorPluginTitle': '이 플러그인을 표시할 수 없습니다', + 'common.needsConnection': '연결이 필요한 작업입니다. TREK가 다시 온라인이 되면 다시 시도하세요.', }; export default common; diff --git a/shared/src/i18n/ko/dashboard.ts b/shared/src/i18n/ko/dashboard.ts index f5ee267e2..8fc734e39 100644 --- a/shared/src/i18n/ko/dashboard.ts +++ b/shared/src/i18n/ko/dashboard.ts @@ -202,5 +202,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': '시간대 추가', 'dashboard.aria.removeTimezone': '{city} 제거', 'dashboard.dayCountRequired': '일수는 필수입니다', + 'dashboard.tripChangeNeedsConnection': + '날짜, 일수, 통화 또는 커버 이미지를 바꾸려면 연결이 필요합니다. 이름, 설명, 알림은 오프라인에서도 바꿀 수 있습니다.', }; export default dashboard; diff --git a/shared/src/i18n/nl/common.ts b/shared/src/i18n/nl/common.ts index f5503d12e..127891387 100644 --- a/shared/src/i18n/nl/common.ts +++ b/shared/src/i18n/nl/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREK is bijgewerkt terwijl dit tabblad open stond. Herlaad om de nieuwe versie te krijgen.', 'common.errorPluginTitle': 'Deze plug-in kon niet worden getoond', + 'common.needsConnection': 'Hiervoor is een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.', }; export default common; diff --git a/shared/src/i18n/nl/dashboard.ts b/shared/src/i18n/nl/dashboard.ts index 1b0eae22d..d4bb6438e 100644 --- a/shared/src/i18n/nl/dashboard.ts +++ b/shared/src/i18n/nl/dashboard.ts @@ -223,5 +223,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Tijdzone toevoegen', 'dashboard.aria.removeTimezone': '{city} verwijderen', 'dashboard.dayCountRequired': 'Aantal dagen is verplicht', + 'dashboard.tripChangeNeedsConnection': + 'Nieuwe data, een ander aantal dagen, een andere valuta of een nieuwe omslag hebben een verbinding nodig. Naam, beschrijving en herinnering kun je ook offline wijzigen.', }; export default dashboard; diff --git a/shared/src/i18n/pl/common.ts b/shared/src/i18n/pl/common.ts index 3ece7b160..5c0aae04d 100644 --- a/shared/src/i18n/pl/common.ts +++ b/shared/src/i18n/pl/common.ts @@ -83,5 +83,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'Dostępna jest nowa wersja', 'common.errorUpdateBody': 'TREK został zaktualizowany, gdy ta karta była otwarta. Odśwież, aby pobrać nową wersję.', 'common.errorPluginTitle': 'Nie udało się wyświetlić tej wtyczki', + 'common.needsConnection': 'To wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.', }; export default common; diff --git a/shared/src/i18n/pl/dashboard.ts b/shared/src/i18n/pl/dashboard.ts index f22785b2d..79674746f 100644 --- a/shared/src/i18n/pl/dashboard.ts +++ b/shared/src/i18n/pl/dashboard.ts @@ -258,5 +258,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Dodaj strefę czasową', 'dashboard.aria.removeTimezone': 'Usuń {city}', 'dashboard.dayCountRequired': 'Liczba dni jest wymagana', + 'dashboard.tripChangeNeedsConnection': + 'Zmiana dat, liczby dni, waluty lub okładki wymaga połączenia. Nazwę, opis i przypomnienie można zmienić także offline.', }; export default dashboard; diff --git a/shared/src/i18n/ru/common.ts b/shared/src/i18n/ru/common.ts index dc0cb5442..ac2b485e3 100644 --- a/shared/src/i18n/ru/common.ts +++ b/shared/src/i18n/ru/common.ts @@ -84,5 +84,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREK обновился, пока эта вкладка была открыта. Обновите страницу, чтобы получить новую версию.', 'common.errorPluginTitle': 'Не удалось показать этот плагин', + 'common.needsConnection': 'Для этого нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.', }; export default common; diff --git a/shared/src/i18n/ru/dashboard.ts b/shared/src/i18n/ru/dashboard.ts index a9204711e..3e9323bf6 100644 --- a/shared/src/i18n/ru/dashboard.ts +++ b/shared/src/i18n/ru/dashboard.ts @@ -256,5 +256,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Добавить часовой пояс', 'dashboard.aria.removeTimezone': 'Удалить {city}', 'dashboard.dayCountRequired': 'Количество дней обязательно', + 'dashboard.tripChangeNeedsConnection': + 'Для смены дат, количества дней, валюты или обложки нужно подключение. Название, описание и напоминание можно менять и офлайн.', }; export default dashboard; diff --git a/shared/src/i18n/sk/common.ts b/shared/src/i18n/sk/common.ts index eb3517c31..ed39b4fab 100644 --- a/shared/src/i18n/sk/common.ts +++ b/shared/src/i18n/sk/common.ts @@ -82,5 +82,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREK bol aktualizovaný, kým bola táto karta otvorená. Znova načítajte stránku pre novú verziu.', 'common.errorPluginTitle': 'Tento plugin sa nepodarilo zobraziť', + 'common.needsConnection': 'Na toto je potrebné pripojenie. Skús to znova, keď bude TREK opäť online.', }; export default common; diff --git a/shared/src/i18n/sk/dashboard.ts b/shared/src/i18n/sk/dashboard.ts index 577815ba9..69882949e 100644 --- a/shared/src/i18n/sk/dashboard.ts +++ b/shared/src/i18n/sk/dashboard.ts @@ -238,5 +238,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Pridať časové pásmo', 'dashboard.aria.removeTimezone': 'Odstrániť {city}', 'dashboard.dayCountRequired': 'Počet dní je povinný', + 'dashboard.tripChangeNeedsConnection': + 'Zmena termínu, počtu dní, meny alebo titulného obrázka potrebuje pripojenie. Názov, popis a pripomienku môžeš zmeniť aj offline.', }; export default dashboard; diff --git a/shared/src/i18n/sv/common.ts b/shared/src/i18n/sv/common.ts index 89bab2e8f..8cfe0c27e 100644 --- a/shared/src/i18n/sv/common.ts +++ b/shared/src/i18n/sv/common.ts @@ -80,5 +80,6 @@ const common: TranslationStrings = { 'common.errorUpdateBody': 'TREK uppdaterades medan den här fliken var öppen. Ladda om för att hämta den nya versionen.', 'common.errorPluginTitle': 'Det här tillägget kunde inte visas', + 'common.needsConnection': 'Det här kräver en anslutning. Försök igen när TREK är online igen.', }; export default common; diff --git a/shared/src/i18n/sv/dashboard.ts b/shared/src/i18n/sv/dashboard.ts index 930a1206b..a4a25008f 100644 --- a/shared/src/i18n/sv/dashboard.ts +++ b/shared/src/i18n/sv/dashboard.ts @@ -220,5 +220,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Lägg till tidszon', 'dashboard.aria.removeTimezone': 'Ta bort {city}', 'dashboard.dayCountRequired': 'Antalet dagar måste anges', + 'dashboard.tripChangeNeedsConnection': + 'Nya datum, ett annat antal dagar, en annan valuta eller en ny omslagsbild kräver en anslutning. Namn, beskrivning och påminnelse kan ändras offline.', }; export default dashboard; diff --git a/shared/src/i18n/th/common.ts b/shared/src/i18n/th/common.ts index 95a2230d2..9e5e0d43f 100644 --- a/shared/src/i18n/th/common.ts +++ b/shared/src/i18n/th/common.ts @@ -77,5 +77,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'มีเวอร์ชันใหม่ให้ใช้งานแล้ว', 'common.errorUpdateBody': 'TREK ได้รับการอัปเดตขณะที่แท็บนี้เปิดอยู่ โหลดซ้ำเพื่อรับเวอร์ชันใหม่', 'common.errorPluginTitle': 'ไม่สามารถแสดงปลั๊กอินนี้ได้', + 'common.needsConnection': 'การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์', }; export default common; diff --git a/shared/src/i18n/th/dashboard.ts b/shared/src/i18n/th/dashboard.ts index 7370a3801..854d10882 100644 --- a/shared/src/i18n/th/dashboard.ts +++ b/shared/src/i18n/th/dashboard.ts @@ -201,5 +201,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'เพิ่มเขตเวลา', 'dashboard.aria.removeTimezone': 'ลบ {city}', 'dashboard.dayCountRequired': 'ต้องระบุจำนวนวัน', + 'dashboard.tripChangeNeedsConnection': + 'การเปลี่ยนวันที่ จำนวนวัน สกุลเงิน หรือภาพปกต้องใช้การเชื่อมต่อ ส่วนชื่อ คำอธิบาย และการแจ้งเตือนเปลี่ยนแบบออฟไลน์ได้', }; export default dashboard; diff --git a/shared/src/i18n/tr/common.ts b/shared/src/i18n/tr/common.ts index b08808739..cacbf02f4 100644 --- a/shared/src/i18n/tr/common.ts +++ b/shared/src/i18n/tr/common.ts @@ -79,5 +79,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'Yeni bir sürüm mevcut', 'common.errorUpdateBody': 'Bu sekme açıkken TREK güncellendi. Yeni sürümü almak için sayfayı yenileyin.', 'common.errorPluginTitle': 'Bu eklenti gösterilemedi', + 'common.needsConnection': 'Bunun için bağlantı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.', }; export default common; diff --git a/shared/src/i18n/tr/dashboard.ts b/shared/src/i18n/tr/dashboard.ts index 8ce328d66..539b0d301 100644 --- a/shared/src/i18n/tr/dashboard.ts +++ b/shared/src/i18n/tr/dashboard.ts @@ -221,5 +221,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Saat dilimi ekle', 'dashboard.aria.removeTimezone': '{city} kaldır', 'dashboard.dayCountRequired': 'Gün sayısı gereklidir', + 'dashboard.tripChangeNeedsConnection': + 'Tarihleri, gün sayısını, para birimini veya kapak görselini değiştirmek bağlantı gerektirir. Ad, açıklama ve hatırlatıcı çevrimdışı da değiştirilebilir.', }; export default dashboard; diff --git a/shared/src/i18n/uk/common.ts b/shared/src/i18n/uk/common.ts index a6e1e2490..f9c8d0462 100644 --- a/shared/src/i18n/uk/common.ts +++ b/shared/src/i18n/uk/common.ts @@ -83,5 +83,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'Доступна нова версія', 'common.errorUpdateBody': 'TREK оновився, поки ця вкладка була відкрита. Перезавантажте, щоб отримати нову версію.', 'common.errorPluginTitle': 'Не вдалося показати цей плагін', + 'common.needsConnection': "Для цього потрібне з'єднання. Спробуйте ще раз, коли TREK знову буде онлайн.", }; export default common; diff --git a/shared/src/i18n/uk/dashboard.ts b/shared/src/i18n/uk/dashboard.ts index 6e023a585..0cad7f260 100644 --- a/shared/src/i18n/uk/dashboard.ts +++ b/shared/src/i18n/uk/dashboard.ts @@ -258,5 +258,7 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': 'Додати часовий пояс', 'dashboard.aria.removeTimezone': 'Вилучити {city}', 'dashboard.dayCountRequired': 'Вкажіть кількість днів', + 'dashboard.tripChangeNeedsConnection': + 'Зміна дат, кількості днів, валюти чи обкладинки потребує зʼєднання. Назву, опис і нагадування можна змінити й офлайн.', }; export default dashboard; diff --git a/shared/src/i18n/vi/common.ts b/shared/src/i18n/vi/common.ts index ad42a6759..f4cf9af85 100644 --- a/shared/src/i18n/vi/common.ts +++ b/shared/src/i18n/vi/common.ts @@ -77,5 +77,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': 'Đã có phiên bản mới', 'common.errorUpdateBody': 'TREK đã được cập nhật trong khi thẻ này đang mở. Hãy tải lại để nhận phiên bản mới.', 'common.errorPluginTitle': 'Không thể hiển thị plugin này', + 'common.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 common; diff --git a/shared/src/i18n/vi/dashboard.ts b/shared/src/i18n/vi/dashboard.ts index 039da08b3..76007889b 100644 --- a/shared/src/i18n/vi/dashboard.ts +++ b/shared/src/i18n/vi/dashboard.ts @@ -204,5 +204,7 @@ const dashboard: TranslationStrings = { 'dashboard.unsplashNoResults': 'Không tìm thấy ảnh nào', 'dashboard.coverSearchError': 'Tìm kiếm trên Unsplash không thành công', 'dashboard.useUnsplashPhoto': 'Dùng ảnh Unsplash của {photographer}', + 'dashboard.tripChangeNeedsConnection': + 'Đổi ngày, số ngày, tiền tệ hoặc ảnh bìa cần có kết nối. Tên, mô tả và lời nhắc có thể đổi khi ngoại tuyến.', }; export default dashboard; diff --git a/shared/src/i18n/zh-TW/common.ts b/shared/src/i18n/zh-TW/common.ts index 1834d5513..cb558e8c4 100644 --- a/shared/src/i18n/zh-TW/common.ts +++ b/shared/src/i18n/zh-TW/common.ts @@ -77,5 +77,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': '有新版本可用', 'common.errorUpdateBody': '此分頁開啟期間 TREK 已更新。請重新載入以取得新版本。', 'common.errorPluginTitle': '無法顯示此外掛程式', + 'common.needsConnection': '此操作需要網路連線。請在 TREK 恢復連線後再試一次。', }; export default common; diff --git a/shared/src/i18n/zh-TW/dashboard.ts b/shared/src/i18n/zh-TW/dashboard.ts index bd349b8a7..83c503b9c 100644 --- a/shared/src/i18n/zh-TW/dashboard.ts +++ b/shared/src/i18n/zh-TW/dashboard.ts @@ -200,5 +200,6 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': '新增時區', 'dashboard.aria.removeTimezone': '移除 {city}', 'dashboard.dayCountRequired': '天數為必填項', + 'dashboard.tripChangeNeedsConnection': '變更日期、天數、幣別或封面需要網路連線。名稱、說明和提醒可以在離線時變更。', }; export default dashboard; diff --git a/shared/src/i18n/zh/common.ts b/shared/src/i18n/zh/common.ts index 8814437f5..db84f34ca 100644 --- a/shared/src/i18n/zh/common.ts +++ b/shared/src/i18n/zh/common.ts @@ -77,5 +77,6 @@ const common: TranslationStrings = { 'common.errorUpdateTitle': '有新版本可用', 'common.errorUpdateBody': '此标签页打开期间 TREK 已更新。请重新加载以获取新版本。', 'common.errorPluginTitle': '无法显示此插件', + 'common.needsConnection': '此操作需要网络连接。请在 TREK 恢复联网后重试。', }; export default common; diff --git a/shared/src/i18n/zh/dashboard.ts b/shared/src/i18n/zh/dashboard.ts index 438bcfdeb..9705a2018 100644 --- a/shared/src/i18n/zh/dashboard.ts +++ b/shared/src/i18n/zh/dashboard.ts @@ -200,5 +200,6 @@ const dashboard: TranslationStrings = { 'dashboard.aria.addTimezone': '添加时区', 'dashboard.aria.removeTimezone': '移除 {city}', 'dashboard.dayCountRequired': '天数为必填项', + 'dashboard.tripChangeNeedsConnection': '更改日期、天数、货币或封面需要网络连接。名称、描述和提醒可以离线更改。', }; export default dashboard;