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;