fix(planner): places put on a day, day names, day notes and trip details changed offline wait in the queue and sync once the connection is back

The day plan wrote straight to the API, so offline a place dropped on a day
snapped back with "Error assigning place", a renamed day or a new day note
failed, and a moved stop, a leg's travel mode or who joins a visit were lost.
Visits (put on a day, taken off, reordered, moved, travellers, leg mode), the
day's name, notes text and travel mode, and the day notes (add, edit, delete,
move) now go through assignmentRepo, dayRepo and dayNoteRepo and the mutation
queue: the cached day changes at once, a visit or note made offline gets a
temporary id that the edits after it wait for, and an online write held behind
an older queued one joins the queue, since none of these carry a version
token. A new order of a day that names a visit made offline is sent with its
real id, or without it when its create failed. Desktop, the phone timeline and
note sheet all write through the store, so both are covered.

The replay reads each answer for what it names: the day answer keeps the
cached notes and visits, the participants answer lands on its visit, and a
visit the server refuses with 409 (a Tour already on that day) fails instead
of waiting as a conflict no retry can solve.

The trip's name, description, reminder and the archive flag wait in the queue
too, from the planner, the phone and the dashboard. What only the server can
do says so instead of failing: new dates, a day count, a currency or a cover,
a new trip, a copy or a delete, members, guests, share and invite links, and
a new order of the days, which moves the dates of the bookings.

Refs #2601
This commit is contained in:
Maurice
2026-10-12 20:17:57 +02:00
parent 92c48fba22
commit bdd8c08dd4
85 changed files with 1557 additions and 88 deletions
-4
View File
@@ -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",
@@ -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(<DayPlanSidebar {...makeDefaultProps({ days: [day], assignments, selectedDayId: 10 })} />)
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 () => {
@@ -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)
})
}
@@ -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();
});
@@ -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);
});
@@ -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);
}
@@ -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(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
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(<TripFormModal {...defaultProps} onSave={created} />);
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();
});
});
});
+16 -1
View File
@@ -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 }])
@@ -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);
+12 -2
View File
@@ -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)
+4 -2
View File
@@ -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
+8 -3
View File
@@ -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]))
@@ -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)
@@ -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<QueuedMutation[]> {
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]);
});
});
+201 -2
View File
@@ -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<Assignment>): Promise<void> {
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<void> {
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<Assignment> {
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<void> {
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<void> {
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<void> {
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<AssignmentParticipant[]> {
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<void> {
const tid = Number(tripId)
const fields: Partial<Assignment> = 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
+167
View File
@@ -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<QueuedMutation[]> {
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');
});
});
+130
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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);
},
};
+69 -1
View File
@@ -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<unknown>, what: string): void {
export function cacheAfterWrite(write: Promise<unknown>, 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<Day>): Promise<void> {
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<Day>): Promise<void> {
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<void> {
const fields: Partial<Day> = {
...('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<void> {
const fields: Partial<Day> = { 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<void> {
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
+81
View File
@@ -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);
});
});
+55 -1
View File
@@ -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<Trip, 'start_date' | 'end_date' | 'currency'> | 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 () => {
+17 -20
View File
@@ -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<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['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'))
+14 -10
View File
@@ -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<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['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 => ({
+21 -3
View File
@@ -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<Day | undefined>
appendDatedDay: (tripId: number | string) => Promise<Day>
deleteDay: (tripId: number | string, dayId: number) => Promise<void>
setDayTransportMode: (tripId: number | string, dayId: number, mode: string | null) => Promise<void>
}
type DayState = Pick<TripStoreState, 'days' | 'assignments' | 'dayNotes' | 'selectedDayId'>
@@ -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'))
}
},
})
+4 -3
View File
@@ -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<TripStoreState>((set, get) => ({
updateTrip: async (tripId: number | string, data: Partial<Trip> & { 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<TripStoreState>((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'))
}
},
+121 -6
View File
@@ -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<void> {
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<number, number>): 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<number, number>): unknown {
const remapped = remapBodyRefs(body, idMap)
if (!remapped || typeof remapped !== 'object' || Array.isArray(remapped)) return remapped
const fields = remapped as Record<string, unknown>
let trimmed: Record<string, unknown> | 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<void> {
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<number, number>): Promise<void> {
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<void> {
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<void> {
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<number, number>): Promise<void> {
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<number, number>): Promise<void> {
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') {
+29
View File
@@ -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');
});
});
+23
View File
@@ -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;
}
@@ -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 () => {
@@ -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'],
]);
});
});
});
+47 -1
View File
@@ -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'],
]);
});
});
});
@@ -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 });
});
});
+1
View File
@@ -87,5 +87,6 @@ const common: TranslationStrings = {
'common.errorUpdateTitle': 'يتوفر إصدار جديد',
'common.errorUpdateBody': 'تم تحديث TREK أثناء فتح علامة التبويب هذه. أعد التحميل للحصول على الإصدار الجديد.',
'common.errorPluginTitle': 'تعذّر عرض هذه الإضافة',
'common.needsConnection': 'يتطلب هذا اتصالاً. حاول مرة أخرى عندما يعود TREK للاتصال.',
};
export default common;
+2
View File
@@ -291,5 +291,7 @@ const dashboard: TranslationStrings = {
'dashboard.aria.addTimezone': 'إضافة منطقة زمنية',
'dashboard.aria.removeTimezone': 'إزالة {city}',
'dashboard.dayCountRequired': 'عدد الأيام مطلوب',
'dashboard.tripChangeNeedsConnection':
'تغيير التواريخ أو عدد الأيام أو العملة أو صورة الغلاف يتطلب اتصالاً. يمكن تغيير الاسم والوصف والتذكير دون اتصال.',
};
export default dashboard;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -80,5 +80,6 @@ const common: TranslationStrings = {
'common.errorUpdateBody':
'Το TREK ενημερώθηκε ενώ αυτή η καρτέλα ήταν ανοιχτή. Κάντε επαναφόρτωση για τη νέα έκδοση.',
'common.errorPluginTitle': 'Δεν ήταν δυνατή η εμφάνιση αυτού του πρόσθετου',
'common.needsConnection': 'Αυτό χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.',
};
export default common;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -78,5 +78,6 @@ const common: TranslationStrings = {
'common.errorUpdateBody':
'このタブを開いている間に TREK が更新されました。再読み込みして新しいバージョンを取得してください。',
'common.errorPluginTitle': 'このプラグインを表示できませんでした',
'common.needsConnection': 'この操作には接続が必要です。TREK がオンラインに戻ってから、もう一度お試しください。',
};
export default common;
+2
View File
@@ -202,5 +202,7 @@ const dashboard: TranslationStrings = {
'dashboard.aria.addTimezone': 'タイムゾーンを追加',
'dashboard.aria.removeTimezone': '{city}を削除',
'dashboard.dayCountRequired': '日数は必須です',
'dashboard.tripChangeNeedsConnection':
'日程、日数、通貨、カバー画像の変更には接続が必要です。名前、説明、リマインダーはオフラインでも変更できます。',
};
export default dashboard;
+1
View File
@@ -77,5 +77,6 @@ const common: TranslationStrings = {
'common.errorUpdateTitle': '새 버전이 있습니다',
'common.errorUpdateBody': '이 탭이 열려 있는 동안 TREK이 업데이트되었습니다. 새로고침하여 새 버전을 받으세요.',
'common.errorPluginTitle': '이 플러그인을 표시할 수 없습니다',
'common.needsConnection': '연결이 필요한 작업입니다. TREK가 다시 온라인이 되면 다시 시도하세요.',
};
export default common;
+2
View File
@@ -202,5 +202,7 @@ const dashboard: TranslationStrings = {
'dashboard.aria.addTimezone': '시간대 추가',
'dashboard.aria.removeTimezone': '{city} 제거',
'dashboard.dayCountRequired': '일수는 필수입니다',
'dashboard.tripChangeNeedsConnection':
'날짜, 일수, 통화 또는 커버 이미지를 바꾸려면 연결이 필요합니다. 이름, 설명, 알림은 오프라인에서도 바꿀 수 있습니다.',
};
export default dashboard;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -84,5 +84,6 @@ const common: TranslationStrings = {
'common.errorUpdateBody':
'TREK обновился, пока эта вкладка была открыта. Обновите страницу, чтобы получить новую версию.',
'common.errorPluginTitle': 'Не удалось показать этот плагин',
'common.needsConnection': 'Для этого нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.',
};
export default common;
+2
View File
@@ -256,5 +256,7 @@ const dashboard: TranslationStrings = {
'dashboard.aria.addTimezone': 'Добавить часовой пояс',
'dashboard.aria.removeTimezone': 'Удалить {city}',
'dashboard.dayCountRequired': 'Количество дней обязательно',
'dashboard.tripChangeNeedsConnection':
'Для смены дат, количества дней, валюты или обложки нужно подключение. Название, описание и напоминание можно менять и офлайн.',
};
export default dashboard;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -77,5 +77,6 @@ const common: TranslationStrings = {
'common.errorUpdateTitle': 'มีเวอร์ชันใหม่ให้ใช้งานแล้ว',
'common.errorUpdateBody': 'TREK ได้รับการอัปเดตขณะที่แท็บนี้เปิดอยู่ โหลดซ้ำเพื่อรับเวอร์ชันใหม่',
'common.errorPluginTitle': 'ไม่สามารถแสดงปลั๊กอินนี้ได้',
'common.needsConnection': 'การทำงานนี้ต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์',
};
export default common;
+2
View File
@@ -201,5 +201,7 @@ const dashboard: TranslationStrings = {
'dashboard.aria.addTimezone': 'เพิ่มเขตเวลา',
'dashboard.aria.removeTimezone': 'ลบ {city}',
'dashboard.dayCountRequired': 'ต้องระบุจำนวนวัน',
'dashboard.tripChangeNeedsConnection':
'การเปลี่ยนวันที่ จำนวนวัน สกุลเงิน หรือภาพปกต้องใช้การเชื่อมต่อ ส่วนชื่อ คำอธิบาย และการแจ้งเตือนเปลี่ยนแบบออฟไลน์ได้',
};
export default dashboard;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -83,5 +83,6 @@ const common: TranslationStrings = {
'common.errorUpdateTitle': 'Доступна нова версія',
'common.errorUpdateBody': 'TREK оновився, поки ця вкладка була відкрита. Перезавантажте, щоб отримати нову версію.',
'common.errorPluginTitle': 'Не вдалося показати цей плагін',
'common.needsConnection': "Для цього потрібне з'єднання. Спробуйте ще раз, коли TREK знову буде онлайн.",
};
export default common;
+2
View File
@@ -258,5 +258,7 @@ const dashboard: TranslationStrings = {
'dashboard.aria.addTimezone': 'Додати часовий пояс',
'dashboard.aria.removeTimezone': 'Вилучити {city}',
'dashboard.dayCountRequired': 'Вкажіть кількість днів',
'dashboard.tripChangeNeedsConnection':
'Зміна дат, кількості днів, валюти чи обкладинки потребує зʼєднання. Назву, опис і нагадування можна змінити й офлайн.',
};
export default dashboard;
+1
View File
@@ -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;
+2
View File
@@ -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;
+1
View File
@@ -77,5 +77,6 @@ const common: TranslationStrings = {
'common.errorUpdateTitle': '有新版本可用',
'common.errorUpdateBody': '此分頁開啟期間 TREK 已更新。請重新載入以取得新版本。',
'common.errorPluginTitle': '無法顯示此外掛程式',
'common.needsConnection': '此操作需要網路連線。請在 TREK 恢復連線後再試一次。',
};
export default common;
+1
View File
@@ -200,5 +200,6 @@ const dashboard: TranslationStrings = {
'dashboard.aria.addTimezone': '新增時區',
'dashboard.aria.removeTimezone': '移除 {city}',
'dashboard.dayCountRequired': '天數為必填項',
'dashboard.tripChangeNeedsConnection': '變更日期、天數、幣別或封面需要網路連線。名稱、說明和提醒可以在離線時變更。',
};
export default dashboard;
+1
View File
@@ -77,5 +77,6 @@ const common: TranslationStrings = {
'common.errorUpdateTitle': '有新版本可用',
'common.errorUpdateBody': '此标签页打开期间 TREK 已更新。请重新加载以获取新版本。',
'common.errorPluginTitle': '无法显示此插件',
'common.needsConnection': '此操作需要网络连接。请在 TREK 恢复联网后重试。',
};
export default common;
+1
View File
@@ -200,5 +200,6 @@ const dashboard: TranslationStrings = {
'dashboard.aria.addTimezone': '添加时区',
'dashboard.aria.removeTimezone': '移除 {city}',
'dashboard.dayCountRequired': '天数为必填项',
'dashboard.tripChangeNeedsConnection': '更改日期、天数、货币或封面需要网络连接。名称、描述和提醒可以离线更改。',
};
export default dashboard;