fix(planner): undoing a place delete brings the place back whole, or is not offered

The undo wrote a new place from nine of its fields and put it on its days as a
bare stop, so notes, website, phone, hours, picture, the visit times, day notes
and travellers were gone while the toast said it was undone. It now writes every
field the place had and plans each visit again with its times, note, end of day,
route flag, travellers and leg modes, and casts the own rating again.

The server takes a booked night, its booking and expense, linked expenses, file
links, other members' ratings and an uploaded picture down with the place, and
writing the place again brings none of that back. Such a delete is no longer
offered for undo. Undo steps that name a deleted place are dropped at the delete
instead of failing later, and adding a place, moving categories and the phone
reorder and optimize now carry their place ids for that.

Taking a stop off a day and clearing a day undo with the visit details too. The
stays are read again when a deleted place had one, here and for collaborators.

Refs #2604
This commit is contained in:
Maurice
2026-10-12 13:44:44 +02:00
parent e70fc334bd
commit 676f6233ed
15 changed files with 593 additions and 72 deletions
@@ -19,6 +19,10 @@ import type { WeatherResult } from '@trek/shared'
import type { Assignment, Place, RouteSegment } from '../../../../types'
import type { MergedItem } from '../../../../utils/dayMerge'
/** The places a day's stops name, so a reorder's undo goes once one of them is deleted. */
const placeIdsOf = (items: Assignment[]): number[] =>
[...new Set(items.map(a => a.place?.id).filter((id): id is number => id != null))]
/**
* State + actions behind the mobile plan timeline. Everything data-shaped comes
* from the planner (same store the desktop page drives, so WS sync / offline /
@@ -173,7 +177,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
if (prevAssignmentIds.length) {
pushUndo(t('undo.reorder'), async () => {
await tripActions.reorderAssignments(tripId, dayId, prevAssignmentIds)
})
}, [dayId], placeIdsOf(dayAssignments))
}
updateRouteForDay(dayId)
} catch (err: unknown) {
@@ -277,7 +281,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
await tripActions.reorderAssignments(tripId, day.id, result.order.map(a => a.id))
pushUndo(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, day.id, prevIds)
})
}, [day.id], placeIdsOf(dayAssignments))
updateRouteForDay(day.id)
toast.success(result.usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
} catch (err: unknown) {
+175
View File
@@ -0,0 +1,175 @@
import { useTripStore, type TripStoreState } from '../../store/tripStore';
import type { Accommodation, Assignment, AssignmentsMap, BudgetItem, Place, Reservation, TripFile } from '../../types';
/** What the server takes down along with a place, as far as the planner has it loaded. */
export interface PlaceDependents {
places: Place[];
tripAccommodations: Accommodation[];
reservations: Reservation[];
budgetItems: BudgetItem[];
files: TripFile[];
userId: number | null | undefined;
}
/**
* Whether deleting these places takes something with them that an undo cannot put back.
*
* An undo writes the place again under a new id. The server deletes a booked night with
* its place, the booking made for it and the expense written against either, unlinks
* any other booking and every file, drops the votes, and removes an uploaded picture
* once nothing else shows it. Writing the place again brings none of that back, so a
* delete that touched any of it is not offered for undo rather than reported as undone
* while half of it stays lost. The traveller's own vote is the exception: it is theirs
* to cast again, and the undo does.
*/
export function deleteLosesDependents(placeIds: number[], dependents: PlaceDependents): boolean {
const ids = new Set(placeIds);
const at = (id: number | null | undefined) => id != null && ids.has(Number(id));
return (
dependents.tripAccommodations.some((stay) => at(stay.place_id)) ||
dependents.reservations.some((r) => at(r.place_id) || at(r.accommodation_place_id)) ||
dependents.budgetItems.some((item) => at(item.place_id)) ||
dependents.files.some((file) => at(file.place_id) || (file.linked_place_ids ?? []).some(at)) ||
dependents.places.some(
(place) =>
ids.has(place.id) &&
(isUploadedImage(place.image_url) || (place.ratings ?? []).some((vote) => vote.user_id !== dependents.userId))
)
);
}
/** A picture uploaded for the place, which the server deletes from disk along with it. */
function isUploadedImage(url: string | null | undefined): boolean {
return typeof url === 'string' && url.startsWith('/uploads/places/');
}
/** Every field the place was created with, for writing it again. */
export function placeRestorePayload(place: Place): Partial<Place> & { name: string } {
return {
name: place.name,
description: place.description,
lat: place.lat,
lng: place.lng,
address: place.address,
category_id: place.category_id,
price: place.price,
currency: place.currency,
place_time: place.place_time,
end_time: place.end_time,
duration_minutes: place.duration_minutes,
notes: place.notes,
image_url: place.image_url,
google_place_id: place.google_place_id,
google_ftid: place.google_ftid,
osm_id: place.osm_id,
amap_poi_id: place.amap_poi_id,
website: place.website,
phone: place.phone,
email: place.email,
opening_hours: place.opening_hours,
transport_mode: place.transport_mode,
// An undone track has to come back as a track, not a bare point.
route_geometry: place.route_geometry,
route_color: place.route_color,
stop_type: place.stop_type,
fill_percent: place.fill_percent,
// The create route takes the tags as ids.
tags: (place.tags ?? []).map((tag) => tag.id).filter((id): id is number => id != null) as unknown as Place['tags'],
};
}
/** One day the place was planned on, with what that visit carried. */
export interface PlaceVisit {
dayId: number;
placeId: number;
orderIndex: number;
time: string | null;
endTime: string | null;
notes: string | null;
endDay: boolean;
routeExcluded: boolean;
legMode: string | null;
incomingLegMode: string | null;
participantIds: number[];
}
/** What one visit carried, for planning it again after it was taken off its day. */
export function visitOf(dayId: number, a: Assignment): PlaceVisit {
return {
dayId,
placeId: a.place.id,
orderIndex: a.order_index,
time: a.assignment_time ?? null,
endTime: a.assignment_end_time ?? null,
notes: a.notes ?? null,
endDay: a.end_day === true,
routeExcluded: a.route_excluded === true,
legMode: a.leg_transport_mode ?? null,
incomingLegMode: a.incoming_leg_transport_mode ?? null,
participantIds: (a.participants ?? []).map((p) => p.user_id),
};
}
export function capturePlaceVisits(assignments: AssignmentsMap, placeIds: number[]): PlaceVisit[] {
return Object.entries(assignments).flatMap(([dayId, items]) =>
items.filter((a) => a.place?.id != null && placeIds.includes(a.place.id)).map((a) => visitOf(Number(dayId), a))
);
}
export type RestoreActions = Pick<
TripStoreState,
| 'addPlace'
| 'assignPlaceToDay'
| 'setAssignmentTimes'
| 'setAssignmentNotes'
| 'setAssignmentEndDay'
| 'setAssignmentRouteExcluded'
| 'setAssignmentParticipants'
| 'setAssignmentLegMode'
| 'ratePlace'
>;
/**
* Writes a deleted place again with everything it had, plans it on the days it was on
* that still exist, each visit with its times, note, travellers and legs, and casts the
* traveller's own vote on it again. Resolves to the new place.
*/
export async function restorePlace(
tripId: number | string,
actions: RestoreActions,
place: Place,
visits: PlaceVisit[],
userId: number | null | undefined
): Promise<Place> {
const restored = await actions.addPlace(tripId, placeRestorePayload(place));
const live = new Set(useTripStore.getState().days.map((d) => d.id));
for (const visit of visits) {
if (visit.placeId !== place.id || !live.has(visit.dayId)) continue;
const assignment = await actions.assignPlaceToDay(tripId, visit.dayId, restored.id, visit.orderIndex);
if (assignment?.id) await restoreVisit(tripId, actions, visit, assignment.id);
}
const ownVote = (place.ratings ?? []).find((vote) => vote.user_id === userId);
if (ownVote) await actions.ratePlace(tripId, restored.id, ownVote.rating);
return restored;
}
/** Gives a visit planned again the times, note, end of day, travellers and legs it had. */
export async function restoreVisit(
tripId: number | string,
actions: Omit<RestoreActions, 'addPlace' | 'assignPlaceToDay' | 'ratePlace'>,
visit: PlaceVisit,
assignmentId: number
) {
const { dayId } = visit;
if (visit.time || visit.endTime) {
await actions.setAssignmentTimes(tripId, dayId, assignmentId, { place_time: visit.time, end_time: visit.endTime });
}
if (visit.notes) await actions.setAssignmentNotes(tripId, dayId, assignmentId, visit.notes);
if (visit.endDay) await actions.setAssignmentEndDay(tripId, dayId, assignmentId, true);
if (visit.routeExcluded) await actions.setAssignmentRouteExcluded(tripId, dayId, assignmentId, true);
if (visit.participantIds.length > 0)
await actions.setAssignmentParticipants(tripId, dayId, assignmentId, visit.participantIds);
if (visit.legMode) await actions.setAssignmentLegMode(tripId, dayId, assignmentId, visit.legMode);
if (visit.incomingLegMode)
await actions.setAssignmentLegMode(tripId, dayId, assignmentId, visit.incomingLegMode, 'incoming');
}
@@ -0,0 +1,113 @@
// FE-TP-DAYCLEAR-001 to FE-TP-DAYCLEAR-002
//
// Taking every place off a day, driven straight through useDayClear. The store's
// writes are recorded stand-ins, so each case can read what the undo puts back.
import { act, renderHook } from '@testing-library/react';
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTripStore } from '../../store/tripStore';
import { useDayClear } from './useDayClear';
type Options = Parameters<typeof useDayClear>[0];
type Undo = () => Promise<void>;
const lake = buildPlace({ id: 1, name: 'Lake' });
const castle = buildPlace({ id: 2, name: 'Castle' });
let writes: Record<string, ReturnType<typeof vi.fn>>;
let fixed: Options;
beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
writes = {
clearDayAssignments: vi.fn(async () => undefined),
assignPlaceToDay: vi.fn(async (_trip: number, _day: number, placeId: number) => ({ id: 500 + placeId })),
setAssignmentTimes: vi.fn(async () => undefined),
setAssignmentNotes: vi.fn(async () => undefined),
setAssignmentEndDay: vi.fn(async () => undefined),
setAssignmentRouteExcluded: vi.fn(async () => undefined),
setAssignmentParticipants: vi.fn(async () => undefined),
setAssignmentLegMode: vi.fn(async () => undefined),
};
useTripStore.setState({
...writes,
days: [buildDay({ id: 10, day_number: 1 })],
assignments: {
10: [
buildAssignment({ id: 102, day_id: 10, place: castle, order_index: 1, notes: 'Tickets', end_day: true }),
buildAssignment({
id: 101,
day_id: 10,
place: lake,
order_index: 0,
assignment_time: '09:00',
assignment_end_time: '10:00',
leg_transport_mode: 'driving',
participants: [{ user_id: 4, username: 'Lena' }],
}),
],
},
} as never);
fixed = {
tripId: 42,
days: [buildDay({ id: 10, day_number: 1 })],
canEditDays: true,
t: (key) => key,
locale: 'en',
toast: { error: vi.fn() },
roadtripVias: { byDay: {}, reanchor: vi.fn(async () => undefined) },
updateRouteForDay: vi.fn(),
pushUndo: vi.fn(),
};
});
async function clearDay() {
const { result } = renderHook(() => useDayClear(fixed));
act(() => {
result.current.handleClearDay(10);
});
await act(async () => {
await result.current.confirmClearDay();
});
const call = vi.mocked(fixed.pushUndo).mock.lastCall;
if (!call) throw new Error('nothing was offered for undo');
return call;
}
describe('useDayClear', () => {
it('FE-TP-DAYCLEAR-001: the undo plans the places back in their order, each with what its visit carried', async () => {
const [label, undo, dayIds, placeIds] = await clearDay();
expect(writes.clearDayAssignments).toHaveBeenCalledWith(42, 10);
expect(label).toBe('undo.clearDay');
expect(dayIds).toEqual([10]);
expect(placeIds).toEqual([1, 2]);
await act(async () => {
await (undo as Undo)();
});
expect(writes.assignPlaceToDay.mock.calls).toEqual([
[42, 10, 1, 0],
[42, 10, 2, 1],
]);
expect(writes.setAssignmentTimes).toHaveBeenCalledWith(42, 10, 501, { place_time: '09:00', end_time: '10:00' });
expect(writes.setAssignmentLegMode).toHaveBeenCalledWith(42, 10, 501, 'driving');
expect(writes.setAssignmentParticipants).toHaveBeenCalledWith(42, 10, 501, [4]);
expect(writes.setAssignmentNotes).toHaveBeenCalledWith(42, 10, 502, 'Tickets');
expect(writes.setAssignmentEndDay).toHaveBeenCalledWith(42, 10, 502, true);
expect(writes.setAssignmentNotes).toHaveBeenCalledTimes(1);
});
it('FE-TP-DAYCLEAR-002: a stop the server would not plan again is left out of the rest of its undo', async () => {
writes.assignPlaceToDay.mockResolvedValueOnce(undefined);
const [, undo] = await clearDay();
await act(async () => {
await (undo as Undo)();
});
expect(writes.setAssignmentTimes).not.toHaveBeenCalled();
expect(writes.setAssignmentNotes).toHaveBeenCalledWith(42, 10, 502, 'Tickets');
});
});
+9 -4
View File
@@ -3,6 +3,7 @@ import { useTripStore } from '../../store/tripStore'
import { dayLabel } from '../../utils/dayLabel'
import type { Day } from '../../types'
import type { RoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { restoreVisit, visitOf } from './placeUndo'
type Translate = (key: string, params?: Record<string, string | number>) => string
@@ -70,11 +71,15 @@ export function useDayClear(options: DayClearOptions): DayClear {
}
updateRouteForDay(dayId)
await onToursChanged?.()
const placeIds = cleared.map(a => a.place?.id).filter((id): id is number => id != null)
const visits = cleared.filter(a => a.place?.id != null).map(a => visitOf(dayId, a))
const placeIds = visits.map(visit => visit.placeId)
pushUndo(t('undo.clearDay'), async () => {
// One after the other: each lands at the position the one before it left free.
for (const [position, placeId] of placeIds.entries()) {
await useTripStore.getState().assignPlaceToDay(tripId, dayId, placeId, position)
// One after the other: each lands at the position the one before it left free,
// with the times, note and travellers it had.
for (const [position, visit] of visits.entries()) {
const store = useTripStore.getState()
const restored = await store.assignPlaceToDay(tripId, dayId, visit.placeId, position)
if (restored?.id) await restoreVisit(tripId, store, visit, restored.id)
}
await onToursChanged?.()
}, [dayId], placeIds)
@@ -1,4 +1,4 @@
// FE-TP-DAYPLAN-001 to FE-TP-DAYPLAN-010
// FE-TP-DAYPLAN-001 to FE-TP-DAYPLAN-010, with 006b
//
// The day plan's edits, driven straight through useDayPlanEdits. The store holds the
// day lists the remove and reorder handlers capture for their undo, every write goes
@@ -39,6 +39,12 @@ function makeActions() {
reorderAssignments: vi.fn(async () => undefined),
updateDayTitle: vi.fn(async () => undefined),
reorderDays: vi.fn(async () => undefined),
setAssignmentTimes: vi.fn(async () => undefined),
setAssignmentNotes: vi.fn(async () => undefined),
setAssignmentEndDay: vi.fn(async () => undefined),
setAssignmentRouteExcluded: vi.fn(async () => undefined),
setAssignmentParticipants: vi.fn(async () => undefined),
setAssignmentLegMode: vi.fn(async () => undefined),
}
}
@@ -191,6 +197,26 @@ describe('useDayPlanEdits', () => {
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 10, 2, 1)
})
it('FE-TP-DAYPLAN-006b: a stop taken off its day comes back with its times, note, travellers and legs', async () => {
const visit = buildAssignment({
id: 104, day_id: 10, place: museum, order_index: 1, assignment_time: '10:00', assignment_end_time: null,
notes: 'Tickets', incoming_leg_transport_mode: 'cycling', participants: [{ user_id: 4, username: 'Lena' }],
})
useTripStore.setState({ assignments: { 10: [day10[0], visit] } })
const { result } = renderEdits()
await act(async () => { await result.current.handleRemoveAssignment(10, 104) })
await act(async () => { await lastUndo()() })
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 10, 4, 1)
expect(actions.setAssignmentTimes).toHaveBeenCalledWith(42, 10, 555, { place_time: '10:00', end_time: null })
expect(actions.setAssignmentNotes).toHaveBeenCalledWith(42, 10, 555, 'Tickets')
expect(actions.setAssignmentParticipants).toHaveBeenCalledWith(42, 10, 555, [4])
expect(actions.setAssignmentLegMode).toHaveBeenCalledWith(42, 10, 555, 'cycling', 'incoming')
expect(actions.setAssignmentEndDay).not.toHaveBeenCalled()
expect(actions.setAssignmentRouteExcluded).not.toHaveBeenCalled()
})
it('FE-TP-DAYPLAN-007: a removed tour reloads the tours both ways, and an unknown row leaves the vias and the undo alone', async () => {
const { result } = renderEdits()
@@ -5,6 +5,7 @@ import type { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
import { useTripStore, type TripStoreState } from '../../store/tripStore'
import type { Accommodation, Place } from '../../types'
import { timedSlot } from '../../utils/dayMerge'
import { restoreVisit, visitOf } from './placeUndo'
import type { PlannerBase, PlannerHistory } from './plannerTypes'
import type { PlaceEdits } from './usePlaceEdits'
import type { RoadtripFeed } from './useRoadtripFeed'
@@ -93,7 +94,7 @@ export function useDayPlanEdits(options: DayPlanEditsOptions) {
const capturedAssignment = (state.assignments[String(dayId)] || []).find(a => a.id === assignmentId)
const capturedPlaceId = capturedAssignment?.place?.id
const removedTour = capturedPlaceId != null && isTourPlace(capturedPlaceId)
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
const capturedVisit = capturedAssignment?.place ? visitOf(dayId, capturedAssignment) : null
// Worked out before the delete, while the day still has the stop the vias
// were measured against. `after_order_index` is a POSITION, so taking a stop
// away moves the ground under every via that follows it: the anchors keep
@@ -111,11 +112,12 @@ export function useDayPlanEdits(options: DayPlanEditsOptions) {
if (removedTour) await reloadTourPlaceIds()
if (plan) await roadtripVias.reanchor(dayId, plan)
updateRouteForDay(dayId)
if (capturedPlaceId != null) {
if (capturedPlaceId != null && capturedVisit) {
const capturedDayId = dayId
const capturedPos = capturedOrderIndex
pushUndo(t('undo.removeAssignment'), async () => {
await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos)
// Back with its times, note and travellers, not as a bare stop.
const restored = await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedVisit.orderIndex)
if (restored?.id) await restoreVisit(tripId, tripActions, capturedVisit, restored.id)
if (removedTour) await reloadTourPlaceIds()
}, [capturedDayId], [capturedPlaceId])
}
@@ -1,4 +1,4 @@
// FE-TP-PLACEEDITS-001 to FE-TP-PLACEEDITS-012
// FE-TP-PLACEEDITS-001 to FE-TP-PLACEEDITS-016
//
// Writes to the trip's places, driven straight through usePlaceEdits. The store holds
// the places and day lists the delete and category handlers capture for their undo,
@@ -12,9 +12,12 @@ import { mutationQueue } from '../../sync/mutationQueue'
import { setAuthed } from '../../sync/authGate'
import { server } from '../../../tests/helpers/msw/server'
import { useTripStore, type TripStoreState } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildAssignment, buildDay, buildPlace, buildReservation, buildTrip } from '../../../tests/helpers/factories'
import type { Accommodation } from '../../types'
import {
buildAssignment, buildBudgetItem, buildDay, buildPlace, buildReservation, buildTrip, buildTripFile,
} from '../../../tests/helpers/factories'
import type { Accommodation, User } from '../../types'
import type { Can, Translate } from './plannerTypes'
import type { RoadtripFeed } from './useRoadtripFeed'
import { usePlaceEdits } from './usePlaceEdits'
@@ -42,6 +45,11 @@ function makeActions() {
refreshDays: vi.fn(async () => undefined),
setAssignmentTimes: vi.fn(async () => undefined),
setAssignmentNotes: vi.fn(async () => undefined),
setAssignmentEndDay: vi.fn(async () => undefined),
setAssignmentRouteExcluded: vi.fn(async () => undefined),
setAssignmentParticipants: vi.fn(async () => undefined),
setAssignmentLegMode: vi.fn(async () => undefined),
ratePlace: vi.fn(async () => undefined),
addFile: vi.fn(async () => undefined),
deletePlace: vi.fn(async (): Promise<{ tourPlaceIds?: number[] } | undefined> => undefined),
deletePlacesMany: vi.fn(async (): Promise<{ tourPlaceIds?: number[] } | undefined> => undefined),
@@ -80,6 +88,7 @@ beforeEach(() => {
tripId: 42, trip, can, toast, t,
tripActions: { ...useTripStore.getState(), ...actions } as unknown as TripStoreState,
places: [lake, castle, summit], allPlaces: [lake, castle, summit], tripAccommodations: [], reservations: [],
loadAccommodations: vi.fn(),
toursEnabled: true, tourPlaceIds: new Set<number>(), invalidateTourPlaceIds: vi.fn(), reloadTourPlaceIds: vi.fn(async () => undefined),
selectedPlaceId: null, setSelectedPlaceId: vi.fn(), selectedDayId: 10,
updateRouteForDay: vi.fn(async () => undefined), pushUndo: vi.fn(), forgetPlace: vi.fn(),
@@ -260,7 +269,8 @@ describe('usePlaceEdits', () => {
expect(saved).toEqual({ id: 900 })
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 11, 900, 2)
expect(fixed.updateRouteForDay).toHaveBeenCalledWith(11)
expect(fixed.pushUndo).toHaveBeenCalledWith('undo.addPlace', expect.any(Function))
// Tied to the new place, so deleting it drops a step that could only fail.
expect(fixed.pushUndo).toHaveBeenCalledWith('undo.addPlace', expect.any(Function), undefined, [900])
await act(async () => { await lastUndo()() })
expect(actions.deletePlace).toHaveBeenCalledWith(42, 900)
})
@@ -343,4 +353,111 @@ describe('usePlaceEdits', () => {
await act(async () => { await result.current.confirmChangeCategory([1], 4) })
expect(toast.error).toHaveBeenLastCalledWith('common.unknownError')
})
// A place planned with everything a traveller fills in, on a day with the times, note,
// travellers and leg of that visit, and the traveller's own vote.
const museum = buildPlace({
id: 5, name: 'Museum', category_id: 3, notes: 'Meine Notiz', website: 'https://museum.example', phone: '+49 89 1',
email: 'info@museum.example', opening_hours: '[]', duration_minutes: 90, place_time: '09:00', currency: 'EUR',
image_url: 'https://images.example/museum.jpg', google_place_id: 'g-5',
tags: [{ id: 7, name: 'Art' }], ratings: [{ user_id: 1, username: 'me', rating: 4 }],
})
const museumVisit = buildAssignment({
id: 105, day_id: 10, place: museum, order_index: 0, assignment_time: '10:00', assignment_end_time: '11:00',
notes: 'Tagesnotiz', end_day: true, route_excluded: true, leg_transport_mode: 'driving',
participants: [{ user_id: 1, username: 'me' }, { user_id: 4, username: 'Lena' }],
})
function seedMuseum() {
useAuthStore.setState({ user: { id: 1 } as User })
useTripStore.setState({ places: [museum, lake, castle], assignments: { 10: [museumVisit] }, budgetItems: [], files: [] })
}
it('FE-TP-PLACEEDITS-013: undoing a delete writes the place again with every field and plans each visit as it was', async () => {
seedMuseum()
const { result } = renderEdits()
act(() => { result.current.setDeletePlaceId(5) })
await act(async () => { await result.current.confirmDeletePlace() })
// Older steps that name the place are dropped with it, not left to fail later.
expect(fixed.forgetPlace).toHaveBeenCalledWith(5)
expect(fixed.pushUndo).toHaveBeenCalledWith('undo.deletePlace', expect.any(Function))
await act(async () => { await lastUndo()() })
expect(actions.addPlace).toHaveBeenCalledWith(42, expect.objectContaining({
name: 'Museum', category_id: 3, notes: 'Meine Notiz', website: 'https://museum.example', phone: '+49 89 1',
email: 'info@museum.example', opening_hours: '[]', duration_minutes: 90, place_time: '09:00', currency: 'EUR',
image_url: 'https://images.example/museum.jpg', google_place_id: 'g-5', tags: [7],
}))
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 10, 900, 0)
expect(actions.setAssignmentTimes).toHaveBeenCalledWith(42, 10, 555, { place_time: '10:00', end_time: '11:00' })
expect(actions.setAssignmentNotes).toHaveBeenCalledWith(42, 10, 555, 'Tagesnotiz')
expect(actions.setAssignmentEndDay).toHaveBeenCalledWith(42, 10, 555, true)
expect(actions.setAssignmentRouteExcluded).toHaveBeenCalledWith(42, 10, 555, true)
expect(actions.setAssignmentParticipants).toHaveBeenCalledWith(42, 10, 555, [1, 4])
expect(actions.setAssignmentLegMode).toHaveBeenCalledWith(42, 10, 555, 'driving')
expect(actions.setAssignmentLegMode).toHaveBeenCalledTimes(1)
expect(actions.ratePlace).toHaveBeenCalledWith(42, 900, 4)
})
it('FE-TP-PLACEEDITS-014: a delete that takes along what writing the place again cannot bring back offers no undo', async () => {
const cases: { name: string; over?: Partial<Options>; store?: Partial<TripStoreState>; place?: Partial<typeof museum> }[] = [
{ name: 'a booked night', over: { tripAccommodations: [stay({ id: 70, place_id: 5 })] } },
{ name: 'a booking at it', over: { reservations: [buildReservation({ id: 80, place_id: 5 })] } },
{ name: 'the hotel of a booking', over: { reservations: [buildReservation({ id: 81, accommodation_place_id: 5 })] } },
{ name: 'a linked expense', store: { budgetItems: [buildBudgetItem({ id: 90, place_id: 5 })] } },
{ name: 'a file of its own', store: { files: [buildTripFile({ id: 91, place_id: 5 })] } },
{ name: 'a file linked to it', store: { files: [buildTripFile({ id: 92, linked_place_ids: [5] })] } },
{ name: 'a vote of another member', place: { ratings: [{ user_id: 4, username: 'Lena', rating: 2 }] } },
{ name: 'an uploaded picture', place: { image_url: '/uploads/places/a.jpg' } },
]
for (const c of cases) {
vi.mocked(fixed.pushUndo).mockClear()
vi.mocked(fixed.forgetPlace).mockClear()
seedMuseum()
useTripStore.setState({ places: [{ ...museum, ...c.place }, lake, castle], ...c.store })
const { result, unmount } = renderEdits(c.over)
act(() => { result.current.setDeletePlaceId(5) })
await act(async () => { await result.current.confirmDeletePlace() })
expect(fixed.pushUndo, c.name).not.toHaveBeenCalled()
expect(fixed.forgetPlace, c.name).toHaveBeenCalledWith(5)
unmount()
}
// The night went with the place, so the stays the planner shows are read again.
expect(fixed.loadAccommodations).toHaveBeenCalledTimes(1)
})
it('FE-TP-PLACEEDITS-015: a bulk delete undoes each place with its own visits, and offers nothing once one of them holds an expense', async () => {
seedMuseum()
const castleVisit = buildAssignment({ id: 111, day_id: 11, place: castle, order_index: 0, notes: 'Burg' })
useTripStore.setState({ assignments: { 10: [museumVisit], 11: [castleVisit] } })
const { result } = renderEdits()
await act(async () => { await result.current.confirmDeletePlaces([5, 2]) })
expect(fixed.forgetPlace).toHaveBeenCalledWith(5)
expect(fixed.forgetPlace).toHaveBeenCalledWith(2)
actions.addPlace.mockResolvedValueOnce({ id: 900 }).mockResolvedValueOnce({ id: 901 })
await act(async () => { await lastUndo()() })
expect(actions.addPlace).toHaveBeenCalledWith(42, expect.objectContaining({ name: 'Museum', notes: 'Meine Notiz' }))
expect(actions.addPlace).toHaveBeenCalledWith(42, expect.objectContaining({ name: 'Castle' }))
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 10, 900, 0)
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 11, 901, 0)
expect(actions.assignPlaceToDay).toHaveBeenCalledTimes(2)
expect(actions.setAssignmentNotes).toHaveBeenCalledWith(42, 11, 555, 'Burg')
vi.mocked(fixed.pushUndo).mockClear()
useTripStore.setState({ budgetItems: [buildBudgetItem({ id: 90, place_id: 2 })] })
await act(async () => { await result.current.confirmDeletePlaces([5, 2]) })
expect(fixed.pushUndo).not.toHaveBeenCalled()
})
it('FE-TP-PLACEEDITS-016: a category move is tied to its places, so deleting one drops it', async () => {
const { result } = renderEdits()
await act(async () => { await result.current.confirmChangeCategory([1, 2], 4) })
expect(fixed.pushUndo).toHaveBeenCalledWith('undo.changeCategory', expect.any(Function), undefined, [1, 2])
})
})
+49 -48
View File
@@ -5,8 +5,10 @@ import type { usePlaceSelection } from '../../hooks/usePlaceSelection'
import type { useRouteCalculation } from '../../hooks/useRouteCalculation'
import type { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
import { translateApiError } from '../../i18n'
import { useAuthStore } from '../../store/authStore'
import { useTripStore, type TripStoreState } from '../../store/tripStore'
import type { Accommodation, Place } from '../../types'
import { capturePlaceVisits, deleteLosesDependents, restorePlace } from './placeUndo'
import type { PlannerBase, PlannerHistory } from './plannerTypes'
import type { PlannerDialogs } from './usePlannerDialogs'
import type { RoadtripFeed } from './useRoadtripFeed'
@@ -23,6 +25,8 @@ interface PlaceEditsOptions
/** Every place of the trip, so a booked night is named whatever the lists leave out. */
allPlaces: TripStoreState['places']
tripAccommodations: Accommodation[]
/** Reads the stays again, which live in the planner rather than the store. */
loadAccommodations: () => void
reservations: TripStoreState['reservations']
toursEnabled: boolean
selectedDayId: TripStoreState['selectedDayId']
@@ -40,7 +44,7 @@ interface PlaceEditsOptions
export function usePlaceEdits(options: PlaceEditsOptions) {
const {
tripId, trip, can, tripActions, toast, t, editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition,
places, allPlaces, tripAccommodations, reservations, selectedDayId, selectedPlaceId, setSelectedPlaceId,
places, allPlaces, tripAccommodations, loadAccommodations, reservations, selectedDayId, selectedPlaceId, setSelectedPlaceId,
toursEnabled, tourPlaceIds, invalidateTourPlaceIds, reloadTourPlaceIds, forgetPlace, pushUndo, updateRouteForDay,
roadtripRoutes, roadtripVias, viaLiesBefore,
} = options
@@ -186,7 +190,7 @@ export function usePlaceEdits(options: PlaceEditsOptions) {
const capturedId = place.id
pushUndo(t('undo.addPlace'), async () => {
await tripActions.deletePlace(tripId, capturedId)
})
}, undefined, [capturedId])
}
// Handed back so the form can link an expense to a place that did not
// exist a moment ago (#1298), the same way the booking modals work.
@@ -211,13 +215,40 @@ export function usePlaceEdits(options: PlaceEditsOptions) {
[can, trip, toursEnabled, isTourPlace]
)
/**
* What the undo of deleting these places needs, taken before the delete while the
* store still has them. Not restorable when the delete takes something along that
* writing the places again would not bring back (see deleteLosesDependents).
*/
const captureForUndo = useCallback((placeIds: number[]) => {
const state = useTripStore.getState()
const userId = useAuthStore.getState().user?.id
const captured = state.places.filter(p => placeIds.includes(p.id))
const lost = deleteLosesDependents(placeIds, {
places: captured, tripAccommodations, reservations, budgetItems: state.budgetItems, files: state.files, userId,
})
return {
places: captured,
visits: lost ? [] : capturePlaceVisits(state.assignments, placeIds),
restorable: !lost && captured.length > 0,
hadStay: tripAccommodations.some(stay => stay.place_id != null && placeIds.includes(stay.place_id)),
userId,
}
}, [tripAccommodations, reservations])
/**
* Steps taken back from the planner's history that still name a deleted place
* could only fail now, so they go as soon as the place does. A booked night went
* with it as well, so the stays are read again.
*/
const afterDelete = useCallback((placeIds: number[], hadStay: boolean) => {
for (const id of placeIds) forgetPlace(id)
if (hadStay) loadAccommodations()
}, [forgetPlace, loadAccommodations])
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return null
const state = useTripStore.getState()
const capturedPlace = state.places.find(p => p.id === deletePlaceId)
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id === deletePlaceId).map(a => ({ dayId: Number(dayId), orderIndex: a.order_index }))
)
const undo = captureForUndo([deletePlaceId])
try {
const deletion = await tripActions.deletePlace(tripId, deletePlaceId)
const deletedTour = Array.isArray(deletion?.tourPlaceIds)
@@ -228,25 +259,10 @@ export function usePlaceEdits(options: PlaceEditsOptions) {
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placeDeleted'))
if (deletedTour) forgetPlace(deletePlaceId)
else if (capturedPlace) {
afterDelete([deletePlaceId], undo.hadStay)
if (!deletedTour && undo.restorable) {
pushUndo(t('undo.deletePlace'), async () => {
const newPlace = await tripActions.addPlace(tripId, {
name: capturedPlace.name,
description: capturedPlace.description,
lat: capturedPlace.lat,
lng: capturedPlace.lng,
address: capturedPlace.address,
category_id: capturedPlace.category_id,
price: capturedPlace.price,
// An undone track has to come back as a track, not a bare point.
route_geometry: capturedPlace.route_geometry,
route_color: capturedPlace.route_color,
})
const live = new Set(useTripStore.getState().days.map(d => d.id))
for (const { dayId, orderIndex } of capturedAssignments) {
if (live.has(dayId)) await tripActions.assignPlaceToDay(tripId, dayId, newPlace.id, orderIndex)
}
await restorePlace(tripId, tripActions, undo.places[0], undo.visits, undo.userId)
})
}
return deletedTour ? deletePlaceId : null
@@ -254,16 +270,12 @@ export function usePlaceEdits(options: PlaceEditsOptions) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
return null
}
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, invalidateTourPlaceIds, reloadTourPlaceIds, isTourPlace])
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, captureForUndo, afterDelete, invalidateTourPlaceIds, reloadTourPlaceIds, isTourPlace])
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
const targetIds = ids ?? deletePlaceIds
if (!targetIds?.length) return
const state = useTripStore.getState()
const capturedPlaces = state.places.filter(p => targetIds.includes(p.id))
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
)
const undo = captureForUndo(targetIds)
try {
const deletion = await tripActions.deletePlacesMany(tripId, targetIds)
const deletedTourIds = Array.isArray(deletion?.tourPlaceIds)
@@ -273,26 +285,15 @@ export function usePlaceEdits(options: PlaceEditsOptions) {
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
if (!ids) setDeletePlaceIds(null)
updateRouteForDay(selectedDayId)
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
if (deletedTourIds.length > 0) deletedTourIds.forEach(forgetPlace)
else if (capturedPlaces.length > 0) {
toast.success(t('trip.toast.placesDeleted', { count: undo.places.length }))
afterDelete(targetIds, undo.hadStay)
if (deletedTourIds.length === 0 && undo.restorable) {
pushUndo(t('undo.deletePlaces'), async () => {
const live = new Set(useTripStore.getState().days.map(d => d.id))
for (const place of capturedPlaces) {
const newPlace = await tripActions.addPlace(tripId, {
name: place.name, description: place.description,
lat: place.lat, lng: place.lng, address: place.address,
category_id: place.category_id, price: place.price,
route_geometry: place.route_geometry, route_color: place.route_color,
})
for (const a of capturedAssignments.filter(x => x.placeId === place.id && live.has(x.dayId))) {
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
}
}
for (const place of undo.places) await restorePlace(tripId, tripActions, place, undo.visits, undo.userId)
})
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, forgetPlace, reloadTourPlaceIds, isTourPlace])
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo, captureForUndo, afterDelete, reloadTourPlaceIds, isTourPlace])
const confirmChangeCategory = useCallback(async (ids: number[], categoryId: number | null) => {
if (!ids.length) return
@@ -314,7 +315,7 @@ export function usePlaceEdits(options: PlaceEditsOptions) {
for (const [key, group] of Object.entries(byPrev)) {
await tripActions.updatePlacesMany(tripId, group, { category_id: key === 'null' ? null : Number(key) })
}
})
}, undefined, captured.map(({ id }) => id))
}
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}, [tripId, toast, pushUndo])
@@ -538,7 +538,7 @@ export function useTripPlanner() {
isTourPlace, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
deletePlaceIsTour, deletePlacesIncludeTours, deletePlaceNote, deletePlacesNote,
} = usePlaceEdits({
tripId, trip, can, tripActions, toast, t, places, allPlaces, tripAccommodations, reservations,
tripId, trip, can, tripActions, toast, t, places, allPlaces, tripAccommodations, loadAccommodations, reservations,
toursEnabled, tourPlaceIds, invalidateTourPlaceIds, reloadTourPlaceIds, selectedPlaceId, setSelectedPlaceId,
selectedDayId, updateRouteForDay, pushUndo, forgetPlace,
editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition, roadtripRoutes, roadtripVias, viaLiesBefore,
@@ -14,6 +14,8 @@ export interface AssignmentsSlice {
setAssignmentRouteExcluded: (tripId: number | string, dayId: number, assignmentId: number, excluded: boolean) => Promise<void>
setAssignmentTimes: (tripId: number | string, dayId: number, assignmentId: number, times: AssignmentTimes) => Promise<void>
setAssignmentNotes: (tripId: number | string, dayId: number, assignmentId: number, notes: string | null) => Promise<void>
setAssignmentParticipants: (tripId: number | string, dayId: number, assignmentId: number, userIds: number[]) => Promise<void>
setAssignmentLegMode: (tripId: number | string, dayId: number, assignmentId: number, mode: string | null, direction?: 'outgoing' | 'incoming') => Promise<void>
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
clearDayAssignments: (tripId: number | string, dayId: number) => Promise<void>
@@ -97,6 +99,21 @@ 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.
setAssignmentParticipants: async (tripId, dayId, assignmentId, userIds) => {
const data = await assignmentsApi.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) },
}))
},
setAssignmentLegMode: async (tripId, dayId, assignmentId, mode, direction = 'outgoing') => {
await assignmentsApi.updateTransport(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) },
}))
},
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
const state = get()
const place = state.places.find(p => p.id === Number.parseInt(String(placeId)))
+10 -6
View File
@@ -529,6 +529,9 @@ export const STATE_APPLIERS: Partial<Record<TrekWsTripEventName, StateApplier>>
},
}
/** The deletes that can take a booked night along with them. */
const STAY_TAKING_EVENTS = new Set<string>(['day:deleted', 'place:deleted', 'accommodation:deleted'])
/**
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
@@ -571,12 +574,13 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
if (tripId) get().loadReservations(tripId)
}
// A deleted day cancels a stay that checked in or out on it, and the stays
// live in page-local planner state rather than this store. This rides the
// planner's existing accommodations:refresh event on purpose, the legacy
// window bus the place image and trip date handlers here use as well.
// Retiring it means moving the stays into a store slice, a change of its own.
if (type === 'day:deleted') window.dispatchEvent(new CustomEvent('accommodations:refresh'))
// A deleted day cancels a stay that checked in or out on it, a deleted place
// the stay booked at it, and the stays live in page-local planner state rather
// than this store. This rides the planner's existing accommodations:refresh
// event on purpose, the legacy window bus the place image and trip date
// handlers here use as well. Retiring it means moving the stays into a store
// slice, a change of its own.
if (STAY_TAKING_EVENTS.has(type)) window.dispatchEvent(new CustomEvent('accommodations:refresh'))
// A reorder/insert re-pins dates and re-stamps booking times server-side, so
// pull the authoritative days + reservations for collaborators.
+13
View File
@@ -516,6 +516,19 @@ describe('tripStore', () => {
window.removeEventListener('accommodations:refresh', listener);
});
it('FE-TSTORE-019d: asks the planner to reload accommodations when a place or a stay is deleted elsewhere', () => {
// A deleted place takes the night booked at it along, and the stays live in the planner.
seedStore(useTripStore, { places: [buildPlace({ id: 500, trip_id: 1 })] });
const listener = vi.fn();
window.addEventListener('accommodations:refresh', listener);
useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 500 });
useTripStore.getState().handleRemoteEvent({ type: 'accommodation:deleted', accommodationId: 70 });
expect(listener).toHaveBeenCalledTimes(2);
window.removeEventListener('accommodations:refresh', listener);
});
it('FE-TSTORE-019c: does not reload accommodations for an edit that leaves the image alone', () => {
const place = buildPlace({ id: 500, trip_id: 1, name: 'Before', image_url: '/uploads/places/a.jpg' });
seedStore(useTripStore, { places: [place] });
@@ -276,7 +276,8 @@ describe('useMPlanTimeline', () => {
const { result } = await renderTimeline(planner)
await act(async () => { result.current.moveRow(result.current.merged[0], 'down') })
await waitFor(() => expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [12, 11, 13]))
expect(planner.pushUndo).toHaveBeenCalledWith('undo.reorder', expect.any(Function))
// Tied to the day and its places, so deleting either drops a step that could only fail.
expect(planner.pushUndo).toHaveBeenCalledWith('undo.reorder', expect.any(Function), [2], expect.arrayContaining([101, 102, 103]))
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
})
@@ -549,7 +550,7 @@ describe('useMPlanTimeline', () => {
const { result } = await renderTimeline(planner)
await act(async () => { await result.current.optimize() })
expect(planner.tripActions.reorderAssignments).toHaveBeenCalledWith(1, 2, [11, 13, 12])
expect(planner.pushUndo).toHaveBeenCalledWith('undo.optimize', expect.any(Function))
expect(planner.pushUndo).toHaveBeenCalledWith('undo.optimize', expect.any(Function), [2], expect.arrayContaining([101, 102, 103]))
expect(planner.updateRouteForDay).toHaveBeenCalledWith(2)
expect(planner.toast.success).toHaveBeenCalledWith('dayplan.toast.routeOptimized')
@@ -516,6 +516,47 @@ describe('assignmentsSlice', () => {
});
});
describe('setAssignmentParticipants / setAssignmentLegMode', () => {
it('FE-ASSIGN-023: the travellers the server answers with, and a leg mode it took, land on the visit', async () => {
const place = buildPlace({ id: 10, trip_id: 1 });
seedStore(useTripStore, { assignments: { '2': [buildAssignment({ id: 5, day_id: 2, place_id: 10, place })] } });
let participantsBody: unknown;
const transportBodies: unknown[] = [];
server.use(
http.put('/api/trips/1/assignments/5/participants', async ({ request }) => {
participantsBody = await request.json();
return HttpResponse.json({ participants: [{ user_id: 4, username: 'Lena' }] });
}),
http.put('/api/trips/1/assignments/5/transport', async ({ request }) => {
transportBodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
await useTripStore.getState().setAssignmentParticipants(1, 2, 5, [4]);
await useTripStore.getState().setAssignmentLegMode(1, 2, 5, 'driving');
await useTripStore.getState().setAssignmentLegMode(1, 2, 5, 'cycling', 'incoming');
expect(participantsBody).toEqual({ user_ids: [4] });
expect(transportBodies).toEqual([{ transport_mode: 'driving' }, { transport_mode: 'cycling', direction: 'incoming' }]);
const visit = useTripStore.getState().assignments['2'][0];
expect(visit.participants).toEqual([{ user_id: 4, username: 'Lena' }]);
expect(visit.leg_transport_mode).toBe('driving');
expect(visit.incoming_leg_transport_mode).toBe('cycling');
});
it('FE-ASSIGN-024: a refused write is thrown and leaves the visit as it was', async () => {
const place = buildPlace({ id: 10, trip_id: 1 });
seedStore(useTripStore, { assignments: { '2': [buildAssignment({ id: 5, day_id: 2, place_id: 10, place })] } });
server.use(
http.put('/api/trips/1/assignments/5/participants', () => HttpResponse.json({ error: 'nope' }, { status: 400 })),
);
await expect(useTripStore.getState().setAssignmentParticipants(1, 2, 5, [4])).rejects.toBeTruthy();
expect(useTripStore.getState().assignments['2'][0].participants).toBeUndefined();
});
});
describe('setAssignments', () => {
it('FE-ASSIGN-019: replaces the whole assignments map', () => {
seedStore(useTripStore, { assignments: { '1': [buildAssignment({ id: 1, day_id: 1 })] } });
+2
View File
@@ -76,6 +76,8 @@ The planner keeps your recent actions in a short undo ring: adding, deleting and
Deleting a day is not in the ring. The question before the delete lists what goes with the day instead; see [Deleting a day](Day-Plans-and-Notes#deleting-a-day). An earlier reorder of the days can still be undone afterwards, minus the day that is gone.
An undone place delete brings the place back with all its details and puts it on its days again with the times, notes and travellers it had there. A place that had a booked night, a booking, a linked expense, files, an uploaded picture or another member's rating is deleted without an undo, because those go with it and cannot be brought back.
## Road trip view
With the [Road trip](Road-Trip) addon enabled, the **Days** / **Road trip** switch above the days column turns the day cards into one continuous drive: arrival times, daily travel times, driving limits, range, search along the route, via points and current hazard warnings. The choice is kept per trip for the browser session. All of it is on its own page: