From bdd8c08dd47770063e7fa8ccf6a9a351908622f8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 20:17:57 +0200 Subject: [PATCH] fix(planner): places put on a day, day names, day notes and trip details changed offline wait in the queue and sync once the connection is back The day plan wrote straight to the API, so offline a place dropped on a day snapped back with "Error assigning place", a renamed day or a new day note failed, and a moved stop, a leg's travel mode or who joins a visit were lost. Visits (put on a day, taken off, reordered, moved, travellers, leg mode), the day's name, notes text and travel mode, and the day notes (add, edit, delete, move) now go through assignmentRepo, dayRepo and dayNoteRepo and the mutation queue: the cached day changes at once, a visit or note made offline gets a temporary id that the edits after it wait for, and an online write held behind an older queued one joins the queue, since none of these carry a version token. A new order of a day that names a visit made offline is sent with its real id, or without it when its create failed. Desktop, the phone timeline and note sheet all write through the store, so both are covered. The replay reads each answer for what it names: the day answer keeps the cached notes and visits, the participants answer lands on its visit, and a visit the server refuses with 409 (a Tour already on that day) fails instead of waiting as a conflict no retry can solve. The trip's name, description, reminder and the archive flag wait in the queue too, from the planner, the phone and the dashboard. What only the server can do says so instead of failing: new dates, a day count, a currency or a cover, a new trip, a copy or a delete, members, guests, share and invite links, and a new order of the days, which moves the dates of the bookings. Refs #2601 --- client/scripts/offline-baseline.json | 4 - .../Planner/DayPlanSidebar.test.tsx | 7 +- .../src/components/Planner/DayPlanSidebar.tsx | 5 +- .../Planner/useLegModeActions.test.ts | 11 +- .../components/Planner/useLegModeActions.ts | 7 +- .../src/components/Planner/usePlaceActions.ts | 11 +- .../components/Trips/TripFormModal.test.tsx | 27 +++ client/src/components/Trips/TripFormModal.tsx | 17 +- .../components/Trips/useTripCoverPicker.ts | 9 + client/src/components/Trips/useTripShare.ts | 14 +- client/src/pages/DashboardPage.test.tsx | 6 +- client/src/pages/dashboard/useDashboard.ts | 11 +- .../src/pages/tripPlanner/useDayPlanEdits.ts | 4 + .../src/repo/assignmentRepo.offline.test.ts | 152 +++++++++++++ client/src/repo/assignmentRepo.ts | 203 +++++++++++++++++- client/src/repo/dayNoteRepo.offline.test.ts | 167 ++++++++++++++ client/src/repo/dayNoteRepo.ts | 130 +++++++++++ client/src/repo/dayRepo.ts | 70 +++++- client/src/repo/tripRepo.offline.test.ts | 81 +++++++ client/src/repo/tripRepo.ts | 56 ++++- client/src/store/slices/assignmentsSlice.ts | 37 ++-- client/src/store/slices/dayNotesSlice.ts | 24 ++- client/src/store/slices/daysSlice.ts | 24 ++- client/src/store/tripStore.ts | 7 +- client/src/sync/mutationQueue.ts | 127 ++++++++++- client/src/utils/needsConnection.test.ts | 29 +++ client/src/utils/needsConnection.ts | 23 ++ .../unit/mobile/trip/useMPlanTimeline.test.ts | 2 +- .../unit/slices/assignmentsSlice.test.ts | 62 +++++- .../tests/unit/slices/dayNotesSlice.test.ts | 48 ++++- .../unit/sync/mutationQueue.days.test.ts | 191 ++++++++++++++++ shared/src/i18n/ar/common.ts | 1 + shared/src/i18n/ar/dashboard.ts | 2 + shared/src/i18n/az/common.ts | 1 + shared/src/i18n/az/dashboard.ts | 2 + shared/src/i18n/br/common.ts | 1 + shared/src/i18n/br/dashboard.ts | 2 + shared/src/i18n/ca/common.ts | 1 + shared/src/i18n/ca/dashboard.ts | 2 + shared/src/i18n/cs/common.ts | 1 + shared/src/i18n/cs/dashboard.ts | 2 + shared/src/i18n/de/common.ts | 1 + shared/src/i18n/de/dashboard.ts | 2 + shared/src/i18n/en/common.ts | 1 + shared/src/i18n/en/dashboard.ts | 2 + shared/src/i18n/es/common.ts | 1 + shared/src/i18n/es/dashboard.ts | 2 + shared/src/i18n/et/common.ts | 1 + shared/src/i18n/et/dashboard.ts | 2 + shared/src/i18n/fr/common.ts | 1 + shared/src/i18n/fr/dashboard.ts | 2 + shared/src/i18n/gr/common.ts | 1 + shared/src/i18n/gr/dashboard.ts | 2 + shared/src/i18n/hu/common.ts | 1 + shared/src/i18n/hu/dashboard.ts | 2 + shared/src/i18n/id/common.ts | 1 + shared/src/i18n/id/dashboard.ts | 2 + shared/src/i18n/it/common.ts | 1 + shared/src/i18n/it/dashboard.ts | 2 + shared/src/i18n/ja/common.ts | 1 + shared/src/i18n/ja/dashboard.ts | 2 + shared/src/i18n/ko/common.ts | 1 + shared/src/i18n/ko/dashboard.ts | 2 + shared/src/i18n/nl/common.ts | 1 + shared/src/i18n/nl/dashboard.ts | 2 + shared/src/i18n/pl/common.ts | 1 + shared/src/i18n/pl/dashboard.ts | 2 + shared/src/i18n/ru/common.ts | 1 + shared/src/i18n/ru/dashboard.ts | 2 + shared/src/i18n/sk/common.ts | 1 + shared/src/i18n/sk/dashboard.ts | 2 + shared/src/i18n/sv/common.ts | 1 + shared/src/i18n/sv/dashboard.ts | 2 + shared/src/i18n/th/common.ts | 1 + shared/src/i18n/th/dashboard.ts | 2 + shared/src/i18n/tr/common.ts | 1 + shared/src/i18n/tr/dashboard.ts | 2 + shared/src/i18n/uk/common.ts | 1 + shared/src/i18n/uk/dashboard.ts | 2 + shared/src/i18n/vi/common.ts | 1 + shared/src/i18n/vi/dashboard.ts | 2 + shared/src/i18n/zh-TW/common.ts | 1 + shared/src/i18n/zh-TW/dashboard.ts | 1 + shared/src/i18n/zh/common.ts | 1 + shared/src/i18n/zh/dashboard.ts | 1 + 85 files changed, 1557 insertions(+), 88 deletions(-) create mode 100644 client/src/repo/assignmentRepo.offline.test.ts create mode 100644 client/src/repo/dayNoteRepo.offline.test.ts create mode 100644 client/src/repo/dayNoteRepo.ts create mode 100644 client/src/repo/tripRepo.offline.test.ts create mode 100644 client/src/utils/needsConnection.test.ts create mode 100644 client/src/utils/needsConnection.ts create mode 100644 client/tests/unit/sync/mutationQueue.days.test.ts 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;