diff --git a/client/src/pages/tripPlanner/useDayPlanEdits.test.ts b/client/src/pages/tripPlanner/useDayPlanEdits.test.ts new file mode 100644 index 000000000..c9db9ccf1 --- /dev/null +++ b/client/src/pages/tripPlanner/useDayPlanEdits.test.ts @@ -0,0 +1,297 @@ +// FE-TP-DAYPLAN-001 to FE-TP-DAYPLAN-010 +// +// 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 +// to a recorded stand-in for the store's actions, and the road trip helpers are small +// stand-ins, so each case can read which vias were carried along. +import { act, renderHook } from '@testing-library/react' +import { useTripStore, type TripStoreState } from '../../store/tripStore' +import { resetAllStores } from '../../../tests/helpers/store' +import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories' +import type { Assignment } from '../../types' +import type { Translate } from './plannerTypes' +import type { RoadtripFeed } from './useRoadtripFeed' +import { useDayPlanEdits } from './useDayPlanEdits' + +type Options = Parameters[0] +type Undo = () => Promise + +const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() } +const t = ((key: string) => key) as Translate + +const lake = buildPlace({ id: 1, name: 'Lake', lat: 50, lng: 10, place_time: '08:00' }) +const castle = buildPlace({ id: 2, name: 'Castle', lat: 50, lng: 11, place_time: '12:00' }) +const summit = buildPlace({ id: 3, name: 'Summit', lat: 50, lng: 12, tour_place_id: 3 }) +const museum = buildPlace({ id: 4, name: 'Museum', lat: 50, lng: 10.5, place_time: '10:00' }) +const day10: Assignment[] = [ + buildAssignment({ id: 101, day_id: 10, place: lake, order_index: 0 }), + buildAssignment({ id: 102, day_id: 10, place: castle, order_index: 1 }), + buildAssignment({ id: 103, day_id: 10, place: summit, order_index: 2 }), +] +const day11: Assignment[] = [buildAssignment({ id: 111, day_id: 11, place: summit, order_index: 0 })] +const reanchoring = { vias: [{ id: 7, after_order_index: 2 }], remove: [] } + +function makeActions() { + return { + assignPlaceToDay: vi.fn(async (): Promise<{ id: number } | undefined> => ({ id: 555 })), + removeAssignment: vi.fn(async () => undefined), + moveAssignment: vi.fn(async () => undefined), + reorderAssignments: vi.fn(async () => undefined), + updateDayTitle: vi.fn(async () => undefined), + reorderDays: vi.fn(async () => undefined), + } +} + +let actions: ReturnType +let vias: { byDay: Record; reanchor: ReturnType } +let fixed: Options + +function renderEdits(over: Partial = {}) { + const options = { ...fixed, ...over } + return renderHook(() => useDayPlanEdits(options)) +} + +const lastUndo = (): Undo => vi.mocked(fixed.pushUndo).mock.lastCall![1] as Undo +// The reorders hand their write on without returning it, so a case waits for the chain to settle. +const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) + +beforeEach(() => { + vi.clearAllMocks() + resetAllStores() + actions = makeActions() + vias = { byDay: { 10: [{ id: 7, after_order_index: 0, lat: 50, lng: 10.2 }] }, reanchor: vi.fn(async () => undefined) } + const assignments = { 10: day10, 11: day11 } + useTripStore.setState({ + places: [lake, castle, summit, museum], + days: [buildDay({ id: 10, day_number: 1 }), buildDay({ id: 11, day_number: 2 }), buildDay({ id: 12, day_number: 3 })], + assignments, + }) + fixed = { + tripId: 42, toast, t, + tripActions: { ...useTripStore.getState(), ...actions } as unknown as TripStoreState, + places: [lake, castle, summit, museum], storedAssignments: assignments, tripAccommodations: [], selectedDayId: 10, + pushUndo: vi.fn(), reloadTourPlaceIds: vi.fn(async () => undefined), updateRouteForDay: vi.fn(async () => undefined), + isTourPlace: (placeId: number) => placeId === 3, + roadtripVias: vias as unknown as RoadtripFeed['roadtripVias'], + roadtripStopsOf: (dayId: number) => (dayId === 10 ? day10 : []), + viasAfterInsert: vi.fn(() => null), + } +}) + +describe('useDayPlanEdits', () => { + it('FE-TP-DAYPLAN-001: with no day chosen nothing is assigned, and a tour already on the day is not added twice', async () => { + const { result } = renderEdits({ selectedDayId: null }) + + let assigned: boolean | undefined + await act(async () => { assigned = await result.current.handleAssignToDay(1) }) + expect(assigned).toBe(false) + expect(toast.error).toHaveBeenCalledWith('trip.toast.selectDay') + + await act(async () => { assigned = await result.current.handleAssignToDay(3, 11) }) + expect(assigned).toBe(false) + expect(actions.assignPlaceToDay).not.toHaveBeenCalled() + }) + + it('FE-TP-DAYPLAN-002: a place with a start of its own is stored behind the row it is drawn after, and carries the vias along', async () => { + vi.mocked(fixed.viasAfterInsert).mockReturnValue(reanchoring) + const { result } = renderEdits() + + let assigned: boolean | undefined + await act(async () => { assigned = await result.current.handleAssignToDay(4) }) + + expect(assigned).toBe(true) + // Behind the lake at eight, ahead of the castle at noon. + expect(fixed.viasAfterInsert).toHaveBeenCalledWith(10, 1, museum) + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 10, 4, 1) + expect(toast.success).toHaveBeenCalledWith('trip.toast.assignedToDay') + expect(vias.reanchor).toHaveBeenCalledWith(10, reanchoring) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(10) + expect(fixed.pushUndo).toHaveBeenCalledWith('undo.assignPlace', expect.any(Function), [10], [4]) + expect(fixed.reloadTourPlaceIds).not.toHaveBeenCalled() + + await act(async () => { await lastUndo()() }) + expect(actions.removeAssignment).toHaveBeenCalledWith(42, 10, 555) + }) + + it('FE-TP-DAYPLAN-003: a tour put on another day reloads the tours, and so does its undo', async () => { + const { result } = renderEdits() + + await act(async () => { await result.current.handleAssignToDay(3, 12, 0) }) + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 12, 3, 0) + expect(fixed.reloadTourPlaceIds).toHaveBeenCalledTimes(1) + expect(vias.reanchor).not.toHaveBeenCalled() + + await act(async () => { await lastUndo()() }) + expect(actions.removeAssignment).toHaveBeenCalledWith(42, 12, 555) + expect(fixed.reloadTourPlaceIds).toHaveBeenCalledTimes(2) + }) + + it('FE-TP-DAYPLAN-004: a failed assignment is reported whatever was thrown, a failed via move still keeps the stop', async () => { + const { result } = renderEdits() + let assigned: boolean | undefined + + actions.assignPlaceToDay.mockRejectedValueOnce(new Error('day.full')) + await act(async () => { assigned = await result.current.handleAssignToDay(1) }) + expect(assigned).toBe(false) + expect(toast.error).toHaveBeenLastCalledWith('day.full') + + actions.assignPlaceToDay.mockRejectedValueOnce('nope') + await act(async () => { assigned = await result.current.handleAssignToDay(1) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + expect(fixed.pushUndo).not.toHaveBeenCalled() + + // A write the store answered without a row is not offered for undo. + actions.assignPlaceToDay.mockResolvedValueOnce(undefined) + await act(async () => { assigned = await result.current.handleAssignToDay(1) }) + expect(assigned).toBe(true) + expect(fixed.pushUndo).not.toHaveBeenCalled() + + vi.mocked(fixed.viasAfterInsert).mockReturnValue(reanchoring) + vias.reanchor.mockRejectedValueOnce(new Error('vias.locked')).mockRejectedValueOnce('nope') + await act(async () => { assigned = await result.current.handleAssignToDay(1) }) + expect(assigned).toBe(true) + expect(toast.error).toHaveBeenLastCalledWith('vias.locked') + await act(async () => { await result.current.handleAssignToDay(1) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + expect(fixed.updateRouteForDay).toHaveBeenLastCalledWith(10) + }) + + it('FE-TP-DAYPLAN-005: a stop moved to another day takes its place along to the via plan, and a failed move rejects', async () => { + vi.mocked(fixed.viasAfterInsert).mockReturnValueOnce(reanchoring) + const { result } = renderEdits() + + await act(async () => { await result.current.handleMoveToDay(101, 10, 11, 0) }) + expect(fixed.viasAfterInsert).toHaveBeenCalledWith(11, 0, lake) + expect(actions.moveAssignment).toHaveBeenCalledWith(42, 101, 10, 11, 0) + expect(vias.reanchor).toHaveBeenCalledWith(11, reanchoring) + + // A row the stored list does not know has no place, and an append moves no via. + await act(async () => { await result.current.handleMoveToDay(999, 12, 11) }) + expect(fixed.viasAfterInsert).toHaveBeenLastCalledWith(11, undefined, undefined) + expect(vias.reanchor).toHaveBeenCalledTimes(1) + + actions.moveAssignment.mockRejectedValueOnce(new Error('offline')) + await expect(act(() => result.current.handleMoveToDay(101, 10, 11))).rejects.toThrow('offline') + }) + + it('FE-TP-DAYPLAN-006: removing a stop moves the vias behind it, and its undo puts it back where it stood', async () => { + vias.byDay[10] = [{ id: 7, after_order_index: 1, lat: 50, lng: 11.5 }] + const { result } = renderEdits() + + await act(async () => { await result.current.handleRemoveAssignment(10, 102) }) + + expect(actions.removeAssignment).toHaveBeenCalledWith(42, 10, 102) + // The via behind the castle now follows the lake, the stop ahead of the one taken away. + expect(vias.reanchor).toHaveBeenCalledWith(10, { vias: [{ id: 7, after_order_index: 0 }], remove: [] }) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(10) + expect(fixed.reloadTourPlaceIds).not.toHaveBeenCalled() + expect(fixed.pushUndo).toHaveBeenCalledWith('undo.removeAssignment', expect.any(Function), [10], [2]) + + await act(async () => { await lastUndo()() }) + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 10, 2, 1) + }) + + 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() + + await act(async () => { await result.current.handleRemoveAssignment(11, 111) }) + expect(fixed.reloadTourPlaceIds).toHaveBeenCalledTimes(1) + await act(async () => { await lastUndo()() }) + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 11, 3, 0) + expect(fixed.reloadTourPlaceIds).toHaveBeenCalledTimes(2) + + vi.mocked(fixed.pushUndo).mockClear() + await act(async () => { await result.current.handleRemoveAssignment(12, 999) }) + expect(actions.removeAssignment).toHaveBeenLastCalledWith(42, 12, 999) + expect(vias.reanchor).not.toHaveBeenCalled() + expect(fixed.pushUndo).not.toHaveBeenCalled() + + actions.removeAssignment.mockRejectedValueOnce(new Error('gone')).mockRejectedValueOnce('nope') + await act(async () => { await result.current.handleRemoveAssignment(10, 101) }) + expect(toast.error).toHaveBeenLastCalledWith('gone') + await act(async () => { await result.current.handleRemoveAssignment(10, 101) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + }) + + it('FE-TP-DAYPLAN-008: a reordered day keeps its hidden rows in place, carries its vias and undoes to the old order', async () => { + vias.byDay[10] = [{ id: 7, after_order_index: 1, lat: 50, lng: 11.5 }] + const { result } = renderEdits() + + // The list hides the castle; the lake and the summit swap around it. + await act(async () => { result.current.handleReorder(10, [103, 101]) }) + await settle() + + expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 10, [103, 102, 101]) + expect(fixed.updateRouteForDay).toHaveBeenCalledWith(10) + // The via drawn behind the castle stays behind it, and the castle is still second. + expect(vias.reanchor).not.toHaveBeenCalled() + expect(fixed.pushUndo).toHaveBeenCalledWith('undo.reorder', expect.any(Function), [10], [1, 2, 3]) + + await act(async () => { await lastUndo()() }) + expect(actions.reorderAssignments).toHaveBeenLastCalledWith(42, 10, [101, 102, 103]) + + // Behind the lake, which is now last, the via has no leg left and goes. + vias.byDay[10] = [{ id: 7, after_order_index: 0, lat: 50, lng: 10.2 }] + const moved = renderEdits() + await act(async () => { moved.result.current.handleReorder(10, [102, 103, 101]) }) + await settle() + expect(vias.reanchor).toHaveBeenCalledWith(10, { vias: [], remove: [7] }) + }) + + it('FE-TP-DAYPLAN-009: a reorder that fails says so, however it fails', async () => { + const { result } = renderEdits() + + actions.reorderAssignments.mockRejectedValueOnce(new Error('conflict')).mockRejectedValueOnce('nope') + await act(async () => { result.current.handleReorder(10, [101, 102, 103]) }) + await settle() + expect(toast.error).toHaveBeenLastCalledWith('conflict') + await act(async () => { result.current.handleReorder(10, [101, 102, 103]) }) + await settle() + expect(toast.error).toHaveBeenLastCalledWith('trip.toast.reorderError') + + // A write that throws before it promises anything is caught too. + actions.reorderAssignments.mockImplementationOnce(() => { throw new Error('sync') }) + toast.error.mockClear() + await act(async () => { result.current.handleReorder(12, []) }) + await settle() + expect(toast.error).toHaveBeenCalledWith('trip.toast.reorderError') + expect(fixed.pushUndo).not.toHaveBeenCalled() + }) + + it('FE-TP-DAYPLAN-010: a day is renamed, the days reorder with an undo that steps aside once the days changed', async () => { + const { result } = renderEdits() + + await act(async () => { await result.current.handleUpdateDayTitle(10, 'Coast') }) + expect(actions.updateDayTitle).toHaveBeenCalledWith(42, 10, 'Coast') + actions.updateDayTitle.mockRejectedValueOnce(new Error('too.long')).mockRejectedValueOnce('nope') + await act(async () => { await result.current.handleUpdateDayTitle(10, 'x') }) + expect(toast.error).toHaveBeenLastCalledWith('too.long') + await act(async () => { await result.current.handleUpdateDayTitle(10, 'x') }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + + await act(async () => { result.current.handleReorderDays([12, 10, 11]) }) + await settle() + expect(actions.reorderDays).toHaveBeenCalledWith(42, [12, 10, 11]) + expect(fixed.pushUndo).toHaveBeenCalledWith('dayplan.reorderUndo', expect.any(Function)) + const undo = lastUndo() + + // A day deleted since then drops out of the old order. + useTripStore.setState({ days: [buildDay({ id: 10 }), buildDay({ id: 12 })] }) + await act(async () => { await undo() }) + expect(actions.reorderDays).toHaveBeenLastCalledWith(42, [10, 12]) + + // A day added since then makes the old order one the server could not take. + useTripStore.setState({ days: [buildDay({ id: 10 }), buildDay({ id: 11 }), buildDay({ id: 12 }), buildDay({ id: 13 })] }) + actions.reorderDays.mockClear() + await act(async () => { await undo() }) + expect(actions.reorderDays).not.toHaveBeenCalled() + + actions.reorderDays.mockRejectedValueOnce(new Error('locked')).mockRejectedValueOnce('nope') + await act(async () => { result.current.handleReorderDays([10, 11, 12]) }) + await settle() + expect(toast.error).toHaveBeenLastCalledWith('locked') + await act(async () => { result.current.handleReorderDays([10, 11, 12]) }) + await settle() + expect(toast.error).toHaveBeenLastCalledWith('dayplan.reorderError') + }) +}) diff --git a/client/src/pages/tripPlanner/useDayPlanEdits.ts b/client/src/pages/tripPlanner/useDayPlanEdits.ts new file mode 100644 index 000000000..e5e648f77 --- /dev/null +++ b/client/src/pages/tripPlanner/useDayPlanEdits.ts @@ -0,0 +1,187 @@ +import { useCallback } from 'react' +import { reanchorAfterRemove, reanchorByStopOrder } from '../../components/Roadtrip/roadtripModel' +import type { useRouteCalculation } from '../../hooks/useRouteCalculation' +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 type { PlannerBase, PlannerHistory } from './plannerTypes' +import type { PlaceEdits } from './usePlaceEdits' +import type { RoadtripFeed } from './useRoadtripFeed' + +interface DayPlanEditsOptions + extends Pick, + Pick, + Pick, + Pick, 'reloadTourPlaceIds'>, + Pick { + selectedDayId: TripStoreState['selectedDayId'] + /** The day lists as stored, so a slot is counted among every stop of the day. */ + storedAssignments: TripStoreState['assignments'] + /** The places the planner lists, without the stops the day lists leave out. */ + places: Place[] + tripAccommodations: Accommodation[] + updateRouteForDay: ReturnType['updateRouteForDay'] +} + +/** + * Edits to the day plan, each with its undo where one makes sense: putting a place on + * a day, moving a stop to another day, taking one off, reordering a day, renaming a + * day and reordering the days. Every edit that shifts the stops of a drawn day carries + * that day's vias along, so the drive keeps the road the traveller chose. + * + * Nothing here runs an effect, so where useTripPlanner calls it changes nothing. + */ +export function useDayPlanEdits(options: DayPlanEditsOptions) { + const { + tripId, tripActions, toast, t, selectedDayId, storedAssignments, places, tripAccommodations, + isTourPlace, reloadTourPlaceIds, pushUndo, updateRouteForDay, roadtripVias, roadtripStopsOf, viasAfterInsert, + } = options + const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => { + const target = dayId || selectedDayId + if (!target) { toast.error(t('trip.toast.selectDay')); return false } + if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(assignment => assignment.place_id === placeId)) return false + const place = places.find(p => p.id === placeId) + // A place with a start of its own is drawn by it, so it is stored there too, the + // way a stop moved over from another day is. Without one it goes where it was put. + const slot = timedSlot(storedAssignments[String(target)] ?? [], tripAccommodations, place?.place_time, position) ?? position + const plan = viasAfterInsert(target, slot, place) + let assignment: Awaited> + try { + assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + return false + } + if (isTourPlace(placeId)) await reloadTourPlaceIds() + toast.success(t('trip.toast.assignedToDay')) + if (assignment?.id) { + const capturedAssignmentId = assignment.id + const capturedTarget = target + pushUndo(t('undo.assignPlace'), async () => { + await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId) + if (isTourPlace(placeId)) await reloadTourPlaceIds() + }, [capturedTarget], [placeId]) + } + if (plan) { + try { await roadtripVias.reanchor(target, plan) } + catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } + } + updateRouteForDay(target) + return true + }, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, isTourPlace, reloadTourPlaceIds, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert]) + + /** + * Moves a stop from the day list onto another day, at a row of that day or at its + * end without one. + * + * It can land in the middle of a day whose road has been drawn, on the row it was + * dropped on or among the stops its start falls between. The vias of that day count + * stops, so every one behind the new stop would shape the leg before the one it was + * drawn on. They are moved the way a place added to the day moves them. Rejects when + * a write fails, so the list can say so and leave its undo out. + */ + const handleMoveToDay = useCallback(async (assignmentId: number, fromDayId: number, toDayId: number, position?: number) => { + const place = (storedAssignments[String(fromDayId)] ?? []).find(a => a.id === assignmentId)?.place + const plan = viasAfterInsert(toDayId, position, place) + await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, position) + if (plan) await roadtripVias.reanchor(toDayId, plan) + }, [tripId, tripActions, storedAssignments, roadtripVias, viasAfterInsert]) + + const handleRemoveAssignment = useCallback(async (dayId: number, assignmentId: number) => { + const state = useTripStore.getState() + 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 + // 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 + // their old numbers and the drive silently reverts to the road the traveller + // steered it off, or bends a leg they never chose. This control is reachable + // from the place inspector in both modes, and it was the one mutating path + // that never corrected them. + const stopsBefore = roadtripStopsOf(dayId) + const removedAt = stopsBefore.findIndex(a => a.id === assignmentId) + const plan = removedAt === -1 + ? null + : reanchorAfterRemove(roadtripVias.byDay[dayId] ?? [], removedAt, stopsBefore.length) + try { + await tripActions.removeAssignment(tripId, dayId, assignmentId) + if (removedTour) await reloadTourPlaceIds() + if (plan) await roadtripVias.reanchor(dayId, plan) + updateRouteForDay(dayId) + if (capturedPlaceId != null) { + const capturedDayId = dayId + const capturedPos = capturedOrderIndex + pushUndo(t('undo.removeAssignment'), async () => { + await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos) + if (removedTour) await reloadTourPlaceIds() + }, [capturedDayId], [capturedPlaceId]) + } + } + catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } + }, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf, reloadTourPlaceIds, isTourPlace]) + + const handleReorder = useCallback((dayId: number, orderedIds: number[]) => { + const assignmentsBefore = useTripStore.getState().assignments[String(dayId)] || [] + const prevIds = assignmentsBefore + .slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) + const placeIdsBefore = [...new Set(assignmentsBefore.map(a => a.place?.id).filter((id): id is number => id != null))] + // The rail counts anchors over routable stops only, so the plan is built in + // that space. A drag here hands a whole new ordering rather than one move, + // and any permutation is possible, so the anchors follow the stop they were + // pinned behind instead of being shifted arithmetically. Without this the + // day's detours stayed on their old numbers and the drive quietly took a + // different road, persisted and visible to every collaborator. + const visible = new Set(orderedIds) + let nextVisible = 0 + const completeOrder = prevIds.map(id => visible.has(id) ? orderedIds[nextVisible++] : id) + const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id) + const stopIdsAfter = completeOrder.filter(id => stopIdsBefore.includes(id)) + const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter) + try { + tripActions.reorderAssignments(tripId, dayId, completeOrder) + .then(async () => { + if (plan.vias.length || plan.remove.length) await roadtripVias.reanchor(dayId, plan) + const capturedDayId = dayId + const capturedPrevIds = prevIds + pushUndo(t('undo.reorder'), async () => { + await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) + }, [capturedDayId], placeIdsBefore) + }) + .catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError'))) + updateRouteForDay(dayId) + } + catch { toast.error(t('trip.toast.reorderError')) } + }, [tripId, toast, pushUndo, updateRouteForDay, t, roadtripVias, roadtripStopsOf]) + + const handleUpdateDayTitle = useCallback(async (dayId, title) => { + try { await tripActions.updateDayTitle(tripId, dayId, title) } + catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } + }, [tripId, toast]) + + const handleReorderDays = useCallback((orderedIds: number[]) => { + 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) + .then(() => { + pushUndo(t('dayplan.reorderUndo'), async () => { + // A day deleted since then drops out of the old order. When the list no + // longer matches the days there are (one was added), the old order is + // not one the server could take, so the undo steps aside. + const live = new Set(useTripStore.getState().days.map(d => d.id)) + const restorable = prevIds.filter(id => live.has(id)) + if (restorable.length !== live.size) return + await tripActions.reorderDays(tripId, restorable) + }) + }) + .catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError'))) + }, [tripId, toast, pushUndo]) + + return { + handleAssignToDay, handleMoveToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle, handleReorderDays, + } +} + +export type DayPlanEdits = ReturnType diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index ea5f1d4bb..9ec1d4a8b 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -10,7 +10,7 @@ import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Mountain, import { resolvePluginIcon } from '../../components/shared/PluginIcon' import { useTranslation } from '../../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, healthApi, mapsApi, placesApi } from '../../api/client' -import { TRANSPORT_TYPES, timedSlot } from '../../utils/dayMerge' +import { TRANSPORT_TYPES } from '../../utils/dayMerge' import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft' import type { BookingImportPreviewItem } from '@trek/shared' import { accommodationRepo } from '../../repo/accommodationRepo' @@ -25,10 +25,7 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket' import { useRouteCalculation } from '../../hooks/useRouteCalculation' import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview' import { useDayClear } from './useDayClear' -import { - reanchorAfterRemove, - reanchorByStopOrder, - isServiceStopType } from '../../components/Roadtrip/roadtripModel' +import { isServiceStopType } from '../../components/Roadtrip/roadtripModel' import { inspectorStay } from '../../components/Roadtrip/stayReading' import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { useTourPlaceIds } from '../../hooks/useTourPlaceIds' @@ -60,14 +57,14 @@ import { useRoadtripAlternatives } from './useRoadtripAlternatives' import { useRoadtripDriveShaping } from './useRoadtripDriveShaping' import { usePlaceEdits } from './usePlaceEdits' import { usePlaceFormOpeners } from './usePlaceFormOpeners' +import { useDayPlanEdits } from './useDayPlanEdits' import { useIsPhone } from '../../mobile/useIsPhone' /** * Trip planner page logic, the big one. Owns the trip store wiring, addon * gating, accommodations/members loading, the tab + resizable-panel + selection - * state, every place/assignment/reservation/transport CRUD handler (with undo) - * and the splash gate. TripPlannerPage stays a wiring container that lays out - * the day/map/places panes and modals. + * state, the reservation and transport saves and the splash gate. TripPlannerPage + * stays a wiring container that lays out the day/map/places panes and modals. * Behaviour is identical to the previous in-component logic. * * Self-contained parts live in the use* sub-hooks beside this file. Each one is @@ -564,147 +561,12 @@ export function useTripPlanner() { roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripCorridor, manualStopTargetFor, isTourPlace, }) - const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => { - const target = dayId || selectedDayId - if (!target) { toast.error(t('trip.toast.selectDay')); return false } - if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(assignment => assignment.place_id === placeId)) return false - const place = places.find(p => p.id === placeId) - // A place with a start of its own is drawn by it, so it is stored there too, the - // way a stop moved over from another day is. Without one it goes where it was put. - const slot = timedSlot(storedAssignments[String(target)] ?? [], tripAccommodations, place?.place_time, position) ?? position - const plan = viasAfterInsert(target, slot, place) - let assignment: Awaited> - try { - assignment = await tripActions.assignPlaceToDay(tripId, target, placeId, slot) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.unknownError')) - return false - } - if (isTourPlace(placeId)) await reloadTourPlaceIds() - toast.success(t('trip.toast.assignedToDay')) - if (assignment?.id) { - const capturedAssignmentId = assignment.id - const capturedTarget = target - pushUndo(t('undo.assignPlace'), async () => { - await tripActions.removeAssignment(tripId, capturedTarget, capturedAssignmentId) - if (isTourPlace(placeId)) await reloadTourPlaceIds() - }, [capturedTarget], [placeId]) - } - if (plan) { - try { await roadtripVias.reanchor(target, plan) } - catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - } - updateRouteForDay(target) - return true - }, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, isTourPlace, reloadTourPlaceIds, storedAssignments, tripAccommodations, roadtripVias, viasAfterInsert]) - - /** - * Moves a stop from the day list onto another day, at a row of that day or at its - * end without one. - * - * It can land in the middle of a day whose road has been drawn, on the row it was - * dropped on or among the stops its start falls between. The vias of that day count - * stops, so every one behind the new stop would shape the leg before the one it was - * drawn on. They are moved the way a place added to the day moves them. Rejects when - * a write fails, so the list can say so and leave its undo out. - */ - const handleMoveToDay = useCallback(async (assignmentId: number, fromDayId: number, toDayId: number, position?: number) => { - const place = (storedAssignments[String(fromDayId)] ?? []).find(a => a.id === assignmentId)?.place - const plan = viasAfterInsert(toDayId, position, place) - await tripActions.moveAssignment(tripId, assignmentId, fromDayId, toDayId, position) - if (plan) await roadtripVias.reanchor(toDayId, plan) - }, [tripId, tripActions, storedAssignments, roadtripVias, viasAfterInsert]) - - const handleRemoveAssignment = useCallback(async (dayId: number, assignmentId: number) => { - const state = useTripStore.getState() - 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 - // 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 - // their old numbers and the drive silently reverts to the road the traveller - // steered it off, or bends a leg they never chose. This control is reachable - // from the place inspector in both modes, and it was the one mutating path - // that never corrected them. - const stopsBefore = roadtripStopsOf(dayId) - const removedAt = stopsBefore.findIndex(a => a.id === assignmentId) - const plan = removedAt === -1 - ? null - : reanchorAfterRemove(roadtripVias.byDay[dayId] ?? [], removedAt, stopsBefore.length) - try { - await tripActions.removeAssignment(tripId, dayId, assignmentId) - if (removedTour) await reloadTourPlaceIds() - if (plan) await roadtripVias.reanchor(dayId, plan) - updateRouteForDay(dayId) - if (capturedPlaceId != null) { - const capturedDayId = dayId - const capturedPos = capturedOrderIndex - pushUndo(t('undo.removeAssignment'), async () => { - await tripActions.assignPlaceToDay(tripId, capturedDayId, capturedPlaceId, capturedPos) - if (removedTour) await reloadTourPlaceIds() - }, [capturedDayId], [capturedPlaceId]) - } - } - catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [tripId, toast, updateRouteForDay, pushUndo, t, roadtripVias, roadtripStopsOf, reloadTourPlaceIds, isTourPlace]) - - const handleReorder = useCallback((dayId: number, orderedIds: number[]) => { - const assignmentsBefore = useTripStore.getState().assignments[String(dayId)] || [] - const prevIds = assignmentsBefore - .slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) - const placeIdsBefore = [...new Set(assignmentsBefore.map(a => a.place?.id).filter((id): id is number => id != null))] - // The rail counts anchors over routable stops only, so the plan is built in - // that space. A drag here hands a whole new ordering rather than one move, - // and any permutation is possible — so the anchors follow the stop they were - // pinned behind instead of being shifted arithmetically. Without this the - // day's detours stayed on their old numbers and the drive quietly took a - // different road, persisted and visible to every collaborator. - const visible = new Set(orderedIds) - let nextVisible = 0 - const completeOrder = prevIds.map(id => visible.has(id) ? orderedIds[nextVisible++] : id) - const stopIdsBefore = roadtripStopsOf(dayId).map(a => a.id) - const stopIdsAfter = completeOrder.filter(id => stopIdsBefore.includes(id)) - const plan = reanchorByStopOrder(roadtripVias.byDay[dayId] ?? [], stopIdsBefore, stopIdsAfter) - try { - tripActions.reorderAssignments(tripId, dayId, completeOrder) - .then(async () => { - if (plan.vias.length || plan.remove.length) await roadtripVias.reanchor(dayId, plan) - const capturedDayId = dayId - const capturedPrevIds = prevIds - pushUndo(t('undo.reorder'), async () => { - await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) - }, [capturedDayId], placeIdsBefore) - }) - .catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError'))) - updateRouteForDay(dayId) - } - catch { toast.error(t('trip.toast.reorderError')) } - }, [tripId, toast, pushUndo, updateRouteForDay, t, roadtripVias, roadtripStopsOf]) - - const handleUpdateDayTitle = useCallback(async (dayId, title) => { - try { await tripActions.updateDayTitle(tripId, dayId, title) } - catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [tripId, toast]) - - const handleReorderDays = useCallback((orderedIds: number[]) => { - 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) - .then(() => { - pushUndo(t('dayplan.reorderUndo'), async () => { - // A day deleted since then drops out of the old order. When the list no - // longer matches the days there are (one was added), the old order is - // not one the server could take, so the undo steps aside. - const live = new Set(useTripStore.getState().days.map(d => d.id)) - const restorable = prevIds.filter(id => live.has(id)) - if (restorable.length !== live.size) return - await tripActions.reorderDays(tripId, restorable) - }) - }) - .catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError'))) - }, [tripId, toast, pushUndo]) + const { + handleReorder, handleReorderDays, handleUpdateDayTitle, handleAssignToDay, handleMoveToDay, handleRemoveAssignment, + } = useDayPlanEdits({ + tripId, tripActions, toast, t, places, storedAssignments, tripAccommodations, selectedDayId, + pushUndo, reloadTourPlaceIds, updateRouteForDay, isTourPlace, roadtripVias, viasAfterInsert, roadtripStopsOf, + }) const { handleAddDay, dayAdd } = useDayAdd({ tripId, trip, days, canEditDays: can('day_edit', trip), t, locale, toast,