mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user