From f9d0f960bc01eadcd5f2ef201141642053472839 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 03:53:41 +0000 Subject: [PATCH] refactor(client): the day plan's assignment edits move into useDayPlanEdits Putting a place on a day, moving a stop to another day, taking one off, reordering a day with the vias it carries along, renaming a day and reordering the days move out of useTripPlanner into useDayPlanEdits. None of it runs an effect, and useTripPlanner calls the hook where the day plan's edits used to sit, ahead of the day add, delete and clear wiring, so the planner returns the same object and behaves exactly as before. --- .../pages/tripPlanner/useDayPlanEdits.test.ts | 297 ++++++++++++++++++ .../src/pages/tripPlanner/useDayPlanEdits.ts | 187 +++++++++++ .../src/pages/tripPlanner/useTripPlanner.ts | 160 +--------- 3 files changed, 495 insertions(+), 149 deletions(-) create mode 100644 client/src/pages/tripPlanner/useDayPlanEdits.test.ts create mode 100644 client/src/pages/tripPlanner/useDayPlanEdits.ts 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,